mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f09f1748ec | |||
| 2768f11dfc | |||
| af9fc95b0e | |||
| 18633c5760 | |||
| c67d914e61 | |||
| e3cb1f82af | |||
| 9b1cc3dbf4 | |||
| e87abe676e | |||
| 4f092f7c4b | |||
| 579fa81ac3 | |||
| 1492549d36 | |||
| 8aa8911671 | |||
| 76aad24f94 | |||
| 2931fd71bb | |||
| 0656e1aee4 | |||
| 4aa7d6e3d1 | |||
| dfa8f25299 | |||
| 69eca2652b | |||
| aef1036ab0 | |||
| d695663e41 | |||
| 288e73cc20 |
+1
-1
@@ -6,7 +6,7 @@ end_of_line = lf
|
||||
insert_final_newline = 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_size = 2
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.js text eol=lf
|
||||
*.cjs text eol=lf
|
||||
*.mjs text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
@@ -29,3 +31,4 @@
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
*.icns binary
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
github: [LukeGus]
|
||||
@@ -25,12 +25,12 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Determine tags
|
||||
id: tags
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
echo "ALL_TAGS=$(IFS=,; echo "${ALL_TAGS[*]}")" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: lukegus
|
||||
@@ -65,18 +65,18 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub (prod only)
|
||||
if: ${{ github.event.inputs.build_type == 'Production' }}
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: bugattiguy527
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push multi-arch image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ env.ALL_TAGS }}
|
||||
build-args: |
|
||||
BUILDKIT_INLINE_CACHE=1
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install system dependencies for AppImage
|
||||
@@ -368,7 +368,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -431,6 +431,7 @@ jobs:
|
||||
env:
|
||||
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: |
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||
BUILD_VERSION="${{ github.run_number }}"
|
||||
@@ -488,6 +489,7 @@ jobs:
|
||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: |
|
||||
if [ "${{ steps.check_certs.outputs.has_certs }}" != "true" ]; then
|
||||
npm run build
|
||||
@@ -893,7 +895,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -956,6 +958,7 @@ jobs:
|
||||
env:
|
||||
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: |
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||
BUILD_VERSION="${{ github.run_number }}"
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -18,12 +18,10 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Run ESLint
|
||||
run: npx eslint .
|
||||
|
||||
+6
-5
@@ -11,8 +11,7 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.vscode/
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
@@ -20,12 +19,14 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
/db/
|
||||
/release/
|
||||
/.claude/
|
||||
/ssl/
|
||||
.env
|
||||
/.mcp.json
|
||||
/uploads/
|
||||
/nul
|
||||
/.vscode/
|
||||
.env
|
||||
electron/build-info.cjs
|
||||
/.mcp.json
|
||||
/CLAUDE.md
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
cask "termix" do
|
||||
version "1.11.2"
|
||||
sha256 "92eb67e25a302474084aeee92071561b037aff9b2d5d6fdafe33f8aeb18cb05d"
|
||||
version "2.2.0"
|
||||
sha256 "a897532f4002ed23b8b8f57312d0e0636e4b44729d0c42dad0030d80f43419cd"
|
||||
|
||||
url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_dmg.dmg"
|
||||
name "Termix"
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
# Repo Stats
|
||||
|
||||
<p align="center">
|
||||
<img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English ·
|
||||
<a href="readme/README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
|
||||
<a href="readme/README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
|
||||
<a href="readme/README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
|
||||
<a href="readme/README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
|
||||
<a href="readme/README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
|
||||
<a href="readme/README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
|
||||
<a href="readme/README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
|
||||
<a href="readme/README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
|
||||
<a href="readme/README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
|
||||
<a href="readme/README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
|
||||
<a href="readme/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/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/README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</a>
|
||||
🇺🇸 English · <a href="readme/README-CN.md">🇨🇳 中文</a> · <a href="readme/README-JA.md">🇯🇵 日本語</a> · <a href="readme/README-KO.md">🇰🇷 한국어</a> · <a href="readme/README-FR.md">🇫🇷 Français</a> · <a href="readme/README-DE.md">🇩🇪 Deutsch</a> · <a href="readme/README-ES.md">🇪🇸 Español</a> · <a href="readme/README-PT.md">🇧🇷 Português</a> · <a href="readme/README-RU.md">🇷🇺 Русский</a> · <a href="readme/README-AR.md">🇸🇦 العربية</a> · <a href="readme/README-HI.md">🇮🇳 हिन्दी</a> · <a href="readme/README-TR.md">🇹🇷 Türkçe</a> · <a href="readme/README-VI.md">🇻🇳 Tiếng Việt</a> · <a href="readme/README-IT.md">🇮🇹 Italiano</a>
|
||||
</p>
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720"></a>
|
||||
|
||||
<p align="center">
|
||||
@@ -34,9 +21,6 @@
|
||||
<img alt="Termix Banner" src=./repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||
</p>
|
||||
|
||||
If you would like, you can support the project here!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# Overview
|
||||
|
||||
<p align="center">
|
||||
@@ -53,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.
|
||||
- **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.
|
||||
- **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
|
||||
@@ -62,16 +46,17 @@ free and self-hosted alternative to Termius available for all platforms.
|
||||
- **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.
|
||||
- **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
|
||||
- **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 dark or light mode based UI. 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.
|
||||
- **Languages** - Built-in support ~30 languages (managed by [Crowdin](https://docs.termix.site/translations))
|
||||
- **Platform Support** - Available as a web app, desktop application (Windows, Linux, and macOS, can be run standalone without Termix backend), PWA, and dedicated mobile/tablet app for iOS and Android.
|
||||
- **SSH Tools** - Create reusable command snippets that execute with a single click. Run one command simultaneously across multiple open terminals.
|
||||
- **Command History** - Auto-complete and view previously ran SSH commands
|
||||
- **Quick Connect** - Connect to a server without having to save the connection data
|
||||
- **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard
|
||||
- **SSH Feature Rich** - Supports jump hosts, Warpgate, TOTP based connections, SOCKS5, host key verification, password autofill, [OPKSSH](https://github.com/openpubkey/opkssh), etc.
|
||||
- **SSH Feature Rich** - Supports jump hosts, Warpgate, TOTP based connections, SOCKS5, host key verification, password autofill, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc.
|
||||
- **Network Graph** - Customize your Dashboard to visualize your homelab based off your SSH connections with status support
|
||||
- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
|
||||
|
||||
@@ -126,7 +111,7 @@ services:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -155,11 +140,23 @@ networks:
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Crowdin">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Crowdin">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Crowdflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
+14
-12
@@ -1,12 +1,14 @@
|
||||
# Stage 1: Install dependencies
|
||||
FROM node:22-slim AS deps
|
||||
FROM node:24-slim AS deps
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
COPY .npmrc ./
|
||||
COPY vendor ./vendor
|
||||
|
||||
RUN npm ci --ignore-scripts --force && \
|
||||
RUN npm ci --ignore-scripts && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 2: Build frontend
|
||||
@@ -26,24 +28,26 @@ WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm rebuild better-sqlite3 --force
|
||||
RUN npm rebuild better-sqlite3
|
||||
|
||||
RUN npm run build:backend
|
||||
|
||||
# Stage 4: Production dependencies only
|
||||
FROM node:22-slim AS production-deps
|
||||
FROM node:24-slim AS production-deps
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
COPY .npmrc ./
|
||||
COPY vendor ./vendor
|
||||
|
||||
RUN npm ci --only=production --ignore-scripts --force && \
|
||||
npm rebuild better-sqlite3 bcryptjs --force && \
|
||||
RUN npm ci --omit=dev --ignore-scripts && \
|
||||
npm rebuild better-sqlite3 bcryptjs && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 5: Final optimized image
|
||||
FROM node:22-slim
|
||||
FROM node:24-slim
|
||||
WORKDIR /app
|
||||
|
||||
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 && \
|
||||
update-ca-certificates && \
|
||||
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 && \
|
||||
chown -R node:node /app && \
|
||||
chmod 755 /app/data /app/uploads /app/data/.opk /app/nginx && \
|
||||
touch /app/nginx/nginx.conf && \
|
||||
chown node:node /app/nginx/nginx.conf
|
||||
mkdir -p /app/data /app/uploads /app/data/.opk /app/nginx /tmp/nginx && \
|
||||
chown -R node:node /app /tmp/nginx && \
|
||||
chmod 755 /app/data /app/uploads /app/data/.opk /app/nginx /tmp/nginx
|
||||
|
||||
COPY docker/nginx.conf /app/nginx/nginx.conf.template
|
||||
COPY docker/nginx-https.conf /app/nginx/nginx-https.conf.template
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
services:
|
||||
termix-dev:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
container_name: termix-dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8081:8080"
|
||||
volumes:
|
||||
- termix-dev-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
NODE_ENV: development
|
||||
depends_on:
|
||||
- guacd-dev
|
||||
networks:
|
||||
- termix-dev-net
|
||||
|
||||
guacd-dev:
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd-dev
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- termix-dev-net
|
||||
|
||||
volumes:
|
||||
termix-dev-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
termix-dev-net:
|
||||
driver: bridge
|
||||
@@ -15,7 +15,7 @@ services:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -7,14 +7,14 @@ PGID=${PGID:-1000}
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
if [ "$PUID" = "0" ]; then
|
||||
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
|
||||
echo "Setting up user permissions (PUID: $PUID, PGID: $PGID)..."
|
||||
|
||||
groupmod -o -g "$PGID" 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"
|
||||
|
||||
@@ -38,7 +38,8 @@ else
|
||||
NGINX_CONF_SOURCE="/app/nginx/nginx.conf.template"
|
||||
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
|
||||
chmod 755 /app/data /app/uploads /app/data/.opk 2>/dev/null || true
|
||||
@@ -132,7 +133,7 @@ EOF
|
||||
fi
|
||||
|
||||
echo "Starting nginx..."
|
||||
nginx -c /app/nginx/nginx.conf
|
||||
nginx -c /tmp/nginx/nginx.conf
|
||||
|
||||
echo "Starting backend services..."
|
||||
cd /app
|
||||
|
||||
+58
-9
@@ -1,7 +1,7 @@
|
||||
worker_processes 1;
|
||||
master_process off;
|
||||
pid /app/nginx/nginx.pid;
|
||||
error_log /app/nginx/logs/error.log warn;
|
||||
pid /tmp/nginx/nginx.pid;
|
||||
error_log /tmp/nginx/error.log warn;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
@@ -11,13 +11,13 @@ http {
|
||||
include /etc/nginx/mime.types;
|
||||
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;
|
||||
proxy_temp_path /app/nginx/proxy_temp;
|
||||
fastcgi_temp_path /app/nginx/fastcgi_temp;
|
||||
uwsgi_temp_path /app/nginx/uwsgi_temp;
|
||||
scgi_temp_path /app/nginx/scgi_temp;
|
||||
client_body_temp_path /tmp/nginx/client_body;
|
||||
proxy_temp_path /tmp/nginx/proxy_temp;
|
||||
fastcgi_temp_path /tmp/nginx/fastcgi_temp;
|
||||
uwsgi_temp_path /tmp/nginx/uwsgi_temp;
|
||||
scgi_temp_path /tmp/nginx/scgi_temp;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
@@ -65,16 +65,32 @@ http {
|
||||
add_header X-Content-Type-Options nosniff 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)$ {
|
||||
root /app/html;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /app/html;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -162,6 +178,15 @@ http {
|
||||
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(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
@@ -337,6 +362,21 @@ http {
|
||||
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 {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
@@ -455,6 +495,15 @@ http {
|
||||
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(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30005;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
+58
-9
@@ -1,7 +1,7 @@
|
||||
worker_processes 1;
|
||||
master_process off;
|
||||
pid /app/nginx/nginx.pid;
|
||||
error_log /app/nginx/logs/error.log warn;
|
||||
pid /tmp/nginx/nginx.pid;
|
||||
error_log /tmp/nginx/error.log warn;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
@@ -11,13 +11,13 @@ http {
|
||||
include /etc/nginx/mime.types;
|
||||
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;
|
||||
proxy_temp_path /app/nginx/proxy_temp;
|
||||
fastcgi_temp_path /app/nginx/fastcgi_temp;
|
||||
uwsgi_temp_path /app/nginx/uwsgi_temp;
|
||||
scgi_temp_path /app/nginx/scgi_temp;
|
||||
client_body_temp_path /tmp/nginx/client_body;
|
||||
proxy_temp_path /tmp/nginx/proxy_temp;
|
||||
fastcgi_temp_path /tmp/nginx/fastcgi_temp;
|
||||
uwsgi_temp_path /tmp/nginx/uwsgi_temp;
|
||||
scgi_temp_path /tmp/nginx/scgi_temp;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
@@ -54,16 +54,32 @@ http {
|
||||
add_header X-Content-Type-Options nosniff 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)$ {
|
||||
root /app/html;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /app/html;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -151,6 +167,15 @@ http {
|
||||
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(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
@@ -326,6 +351,21 @@ http {
|
||||
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 {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
@@ -444,6 +484,15 @@ http {
|
||||
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(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30005;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"asar": false,
|
||||
"asar": true,
|
||||
"asarUnpack": ["dist/backend/**/*", "node_modules/**/*.node"],
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"electron/**/*",
|
||||
@@ -130,7 +131,6 @@
|
||||
"entitlementsInherit": "build/entitlements.mas.inherit.plist",
|
||||
"hardenedRuntime": false,
|
||||
"gatekeeperAssess": false,
|
||||
"asar": false,
|
||||
"type": "distribution",
|
||||
"category": "public.app-category.developer-tools",
|
||||
"artifactName": "termix_macos_${arch}_mas.${ext}",
|
||||
|
||||
+1667
-23
File diff suppressed because it is too large
Load Diff
+33
-3
@@ -1,5 +1,4 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
const { clipboard } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
|
||||
@@ -10,15 +9,46 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
|
||||
getSetting: (key) => ipcRenderer.invoke("get-setting", key),
|
||||
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"),
|
||||
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),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld("electronClipboard", {
|
||||
writeText: (text) => clipboard.writeText(text),
|
||||
readText: () => clipboard.readText(),
|
||||
writeText: (text) => ipcRenderer.invoke("clipboard-write-text", text),
|
||||
readText: () => ipcRenderer.invoke("clipboard-read-text"),
|
||||
});
|
||||
|
||||
window.IS_ELECTRON = true;
|
||||
|
||||
+20
-2
@@ -2,6 +2,7 @@ import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import unusedImports from "eslint-plugin-unused-imports";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { globalIgnores } from "eslint/config";
|
||||
|
||||
@@ -12,19 +13,36 @@ export default tseslint.config([
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs["recommended-latest"],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"unused-imports": unusedImports,
|
||||
},
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
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-unused-expressions": "warn",
|
||||
"no-empty": "warn",
|
||||
"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",
|
||||
},
|
||||
},
|
||||
|
||||
Generated
+3395
-6852
File diff suppressed because it is too large
Load Diff
+136
-119
@@ -1,15 +1,20 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"version": "2.2.0",
|
||||
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.12.0",
|
||||
"npm": ">=11"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "npx prettier . --write",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"postinstall": "node scripts/patch-app-builder-lib.cjs",
|
||||
"prebuild": "node scripts/write-electron-build-info.cjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"type-check": "tsc --noEmit",
|
||||
@@ -22,150 +27,162 @@
|
||||
"generate:openapi": "tsc -p tsconfig.node.json && node ./dist/backend/backend/swagger.js",
|
||||
"preview": "vite preview",
|
||||
"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",
|
||||
"build:win-portable": "npm run build && npm run electron:rebuild && electron-builder --win --dir",
|
||||
"build:win-installer": "npm run build && npm run electron:rebuild && electron-builder --win --publish=never",
|
||||
"build:linux-portable": "npm run build && npm run electron:rebuild && electron-builder --linux --dir",
|
||||
"build:linux-appimage": "npm run build && npm run electron:rebuild && electron-builder --linux AppImage",
|
||||
"build:linux-targz": "npm run build && npm run electron:rebuild && electron-builder --linux tar.gz",
|
||||
"build:mac": "npm run build && npm run electron:rebuild && electron-builder --mac --universal"
|
||||
"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 && npm run electron:patch-builder && electron-builder --win --publish=never",
|
||||
"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 && npm run electron:patch-builder && electron-builder --linux AppImage",
|
||||
"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 && 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": {
|
||||
"@codemirror/autocomplete": "^6.18.7",
|
||||
"@codemirror/commands": "^6.3.3",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@codemirror/view": "^6.23.1",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"axios": "^1.15.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"body-parser": "^2.2.2",
|
||||
"chalk": "^5.6.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^5.2.1",
|
||||
"guacamole-lite": "^1.2.0",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"jose": "^6.2.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jszip": "^3.10.1",
|
||||
"motion": "^12.38.0",
|
||||
"multer": "^2.1.1",
|
||||
"nanoid": "^5.1.9",
|
||||
"qrcode": "^1.5.4",
|
||||
"react-is": "^19.2.5",
|
||||
"socks": "^2.8.7",
|
||||
"speakeasy": "^2.0.0",
|
||||
"ssh2": "^1.17.0",
|
||||
"undici": "^7.0.0",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/search": "^6.7.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.41.1",
|
||||
"@commitlint/cli": "^20.5.0",
|
||||
"@commitlint/config-conventional": "^20.5.0",
|
||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||
"@electron/notarize": "^3.1.1",
|
||||
"@electron/rebuild": "^4.0.4",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@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-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cookie-parser": "^1.4.9",
|
||||
"@types/cytoscape": "^3.21.9",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/guacamole-common-js": "^1.5.5",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react-grid-layout": "^1.3.6",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/speakeasy": "^2.0.10",
|
||||
"@uiw/codemirror-extensions-langs": "^4.24.1",
|
||||
"@uiw/codemirror-theme-github": "^4.25.4",
|
||||
"@uiw/react-codemirror": "^4.24.1",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@uiw/codemirror-extensions-langs": "^4.25.9",
|
||||
"@uiw/codemirror-theme-github": "^4.25.9",
|
||||
"@uiw/react-codemirror": "^4.25.9",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@xterm/addon-clipboard": "^0.2.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-unicode11": "^0.8.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"axios": "^1.10.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"chalk": "^4.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"cytoscape": "^3.33.1",
|
||||
"dotenv": "^17.2.0",
|
||||
"drizzle-orm": "^0.44.3",
|
||||
"express": "^5.1.0",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"guacamole-lite": "^1.2.0",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"i18n-auto-translation": "^2.2.3",
|
||||
"i18next": "^25.4.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jose": "^5.2.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.525.0",
|
||||
"multer": "^2.0.2",
|
||||
"nanoid": "^5.1.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-fetch": "^3.3.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.0",
|
||||
"react-cytoscapejs": "^2.0.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-h5-audio-player": "^3.10.1",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-pdf": "^10.1.0",
|
||||
"react-photo-view": "^1.2.7",
|
||||
"react-player": "^3.3.3",
|
||||
"react-resizable-panels": "^3.0.3",
|
||||
"react-simple-keyboard": "^3.8.120",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"recharts": "^3.2.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"socks": "^2.8.7",
|
||||
"sonner": "^2.0.7",
|
||||
"speakeasy": "^2.0.0",
|
||||
"ssh2": "^1.16.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"wait-on": "^9.0.1",
|
||||
"ws": "^8.18.3",
|
||||
"zod": "^4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.1.0",
|
||||
"@commitlint/config-conventional": "^20.0.0",
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"@electron/rebuild": "^3.7.2",
|
||||
"@eslint/js": "^9.34.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"concurrently": "^9.2.1",
|
||||
"electron": "^38.0.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"cytoscape": "^3.33.2",
|
||||
"electron": "^41.3.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"globals": "^17.5.0",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.3",
|
||||
"prettier": "3.6.2",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.40.0",
|
||||
"vite": "^7.1.5"
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lint-staged": "^16.4.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"prettier": "3.8.3",
|
||||
"react": "^19.2.5",
|
||||
"react-cytoscapejs": "^2.0.0",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-grid-layout": "^2.2.3",
|
||||
"react-h5-audio-player": "^3.10.2",
|
||||
"react-hook-form": "^7.73.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-pdf": "^10.4.1",
|
||||
"react-photo-view": "^1.2.7",
|
||||
"react-resizable-panels": "^4.10.0",
|
||||
"react-simple-keyboard": "^3.8.196",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"recharts": "^3.8.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.59.0",
|
||||
"vite": "^8.0.10",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
"*.{ts,tsx}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{js,jsx}": [
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{json,css,md}": [
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
+5
-15
@@ -1,8 +1,5 @@
|
||||
const CACHE_NAME = "termix-v1";
|
||||
const CACHE_NAME = "termix-static-v2";
|
||||
const STATIC_ASSETS = [
|
||||
"/",
|
||||
"/index.html",
|
||||
"/manifest.json",
|
||||
"/favicon.ico",
|
||||
"/icons/48x48.png",
|
||||
"/icons/128x128.png",
|
||||
@@ -55,8 +52,8 @@ self.addEventListener("fetch", (event) => {
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname.startsWith("/ssh/opkssh-chooser/") ||
|
||||
url.pathname.startsWith("/ssh/opkssh-callback/")
|
||||
url.pathname.startsWith("/host/opkssh-chooser/") ||
|
||||
url.pathname.startsWith("/host/opkssh-callback/")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -66,18 +63,11 @@ self.addEventListener("fetch", (event) => {
|
||||
}
|
||||
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(request).catch(() => {
|
||||
return caches.match("/index.html");
|
||||
}),
|
||||
);
|
||||
event.respondWith(fetch(request));
|
||||
return;
|
||||
}
|
||||
|
||||
const isStaticAsset = STATIC_ASSETS.some((asset) => {
|
||||
if (asset === "/") return url.pathname === "/";
|
||||
return url.pathname === asset || url.pathname.startsWith("/assets/");
|
||||
});
|
||||
const isStaticAsset = STATIC_ASSETS.some((asset) => url.pathname === asset);
|
||||
|
||||
if (!isStaticAsset) {
|
||||
return;
|
||||
|
||||
+42
-45
@@ -1,20 +1,7 @@
|
||||
# إحصائيات المستودع
|
||||
|
||||
<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-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>
|
||||
<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>
|
||||
</p>
|
||||
|
||||

|
||||
@@ -34,9 +21,6 @@
|
||||
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||
</p>
|
||||
|
||||
إذا كنت ترغب في ذلك، يمكنك دعم المشروع هنا!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# نظرة عامة
|
||||
|
||||
<p align="center">
|
||||
@@ -44,33 +28,34 @@
|
||||
<img alt="Termix Banner" src=../public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||
</p>
|
||||
|
||||
Termix هي منصة مفتوحة المصدر ومجانية للأبد وذاتية الاستضافة لإدارة الخوادم بشكل شامل. توفر حلاً متعدد المنصات لإدارة خوادمك وبنيتك التحتية من خلال واجهة واحدة وسهلة الاستخدام. يوفر Termix الوصول إلى طرفية SSH، وقدرات إنشاء أنفاق SSH، وإدارة الملفات عن بُعد، والعديد من الأدوات الأخرى. يُعد Termix البديل المثالي المجاني وذاتي الاستضافة لـ Termius المتاح لجميع المنصات.
|
||||
Termix هي منصة مفتوحة المصدر ومجانية للأبد وذاتية الاستضافة لإدارة الخوادم بشكل شامل. توفر حلاً متعدد المنصات لإدارة خوادمك وبنيتك التحتية من خلال واجهة واحدة وسهلة الاستخدام. يوفر Termix الوصول إلى طرفية SSH، والتحكم في سطح المكتب البعيد (RDP، VNC، Telnet)، وقدرات إنشاء أنفاق SSH، وإدارة ملفات SSH عن بُعد، والعديد من الأدوات الأخرى. يُعد Termix البديل المثالي المجاني وذاتي الاستضافة لـ Termius المتاح لجميع المنصات.
|
||||
|
||||
# الميزات
|
||||
|
||||
- **الوصول إلى طرفية SSH** - طرفية كاملة الميزات مع دعم تقسيم الشاشة (حتى 4 لوحات) مع نظام علامات تبويب شبيه بالمتصفح. يتضمن دعم تخصيص الطرفية بما في ذلك السمات الشائعة والخطوط والمكونات الأخرى
|
||||
- **الوصول إلى سطح المكتب البعيد** - دعم RDP و VNC و Telnet عبر المتصفح مع تخصيص كامل وتقسيم الشاشة
|
||||
- **إدارة أنفاق SSH** - إنشاء وإدارة أنفاق SSH مع إعادة الاتصال التلقائي ومراقبة الحالة ودعم اتصالات -l أو -r
|
||||
- **مدير الملفات عن بُعد** - إدارة الملفات مباشرة على الخوادم البعيدة مع دعم عرض وتحرير الكود والصور والصوت والفيديو. رفع وتنزيل وإعادة تسمية وحذف ونقل الملفات بسلاسة مع دعم sudo
|
||||
- **إدارة Docker** - تشغيل وإيقاف وتعليق وحذف الحاويات. عرض إحصائيات الحاويات. التحكم في الحاوية باستخدام طرفية docker exec. لم يُصمم ليحل محل Portainer أو Dockge بل لإدارة حاوياتك ببساطة مقارنة بإنشائها
|
||||
- **مدير مضيفات SSH** - حفظ وتنظيم وإدارة اتصالات SSH الخاصة بك باستخدام العلامات والمجلدات، وحفظ بيانات تسجيل الدخول القابلة لإعادة الاستخدام بسهولة مع إمكانية أتمتة نشر مفاتيح SSH
|
||||
- **إحصائيات الخادم** - عرض استخدام المعالج والذاكرة والقرص إلى جانب الشبكة ووقت التشغيل ومعلومات النظام وجدار الحماية ومراقب المنافذ على معظم الخوادم المبنية على Linux
|
||||
- **لوحة التحكم** - عرض معلومات الخادم بنظرة واحدة على لوحة التحكم
|
||||
- **RBAC** - إنشاء الأدوار ومشاركة المضيفات عبر المستخدمين/الأدوار
|
||||
- **مصادقة المستخدمين** - إدارة آمنة للمستخدمين مع ضوابط إدارية ودعم OIDC و 2FA (TOTP). عرض جلسات المستخدمين النشطة عبر جميع المنصات وإلغاء الصلاحيات. ربط حسابات OIDC/المحلية معاً
|
||||
- **تشفير قاعدة البيانات** - يُخزَّن الخادم الخلفي كملفات قاعدة بيانات SQLite مشفرة. اطلع على [الوثائق](https://docs.termix.site/security) لمزيد من المعلومات
|
||||
- **تصدير/استيراد البيانات** - تصدير واستيراد مضيفات SSH وبيانات الاعتماد وبيانات مدير الملفات
|
||||
- **إعداد SSL تلقائي** - إنشاء وإدارة شهادات SSL مدمجة مع إعادة التوجيه إلى HTTPS
|
||||
- **واجهة مستخدم حديثة** - واجهة نظيفة متوافقة مع سطح المكتب والهاتف المحمول مبنية بـ React و Tailwind CSS و Shadcn. الاختيار بين الوضع الداكن أو الفاتح. استخدام مسارات URL لفتح أي اتصال في وضع ملء الشاشة
|
||||
- **اللغات** - دعم مدمج لحوالي 30 لغة (تُدار بواسطة [Crowdin](https://docs.termix.site/translations))
|
||||
- **دعم المنصات** - متاح كتطبيق ويب، وتطبيق سطح مكتب (Windows و Linux و macOS)، و PWA، وتطبيق مخصص للهاتف المحمول/الجهاز اللوحي لـ iOS و Android
|
||||
- **أدوات SSH** - إنشاء مقتطفات أوامر قابلة لإعادة الاستخدام تُنفَّذ بنقرة واحدة. تشغيل أمر واحد في وقت واحد عبر عدة طرفيات مفتوحة
|
||||
- **سجل الأوامر** - الإكمال التلقائي وعرض أوامر SSH التي تم تنفيذها سابقاً
|
||||
- **الاتصال السريع** - الاتصال بخادم دون الحاجة إلى حفظ بيانات الاتصال
|
||||
- **لوحة الأوامر** - اضغط مرتين على Shift الأيسر للوصول السريع إلى اتصالات SSH باستخدام لوحة المفاتيح
|
||||
- **ميزات SSH الغنية** - دعم مضيفات القفز، Warpgate، الاتصالات المبنية على TOTP، SOCKS5، التحقق من مفتاح المضيف، الملء التلقائي لكلمة المرور، [OPKSSH](https://github.com/openpubkey/opkssh)، وغيرها
|
||||
- **الرسم البياني للشبكة** - تخصيص لوحة التحكم لتصور مختبرك المنزلي بناءً على اتصالات SSH مع دعم الحالة
|
||||
- **علامات التبويب الدائمة** - تبقى جلسات SSH وعلامات التبويب مفتوحة عبر الأجهزة/التحديثات إذا تم تفعيلها في ملف تعريف المستخدم
|
||||
- **الوصول إلى طرفية SSH** - طرفية كاملة الميزات مع دعم تقسيم الشاشة (حتى 4 لوحات) مع نظام علامات تبويب شبيه بالمتصفح. يتضمن دعم تخصيص الطرفية بما في ذلك سمات الطرفية الشائعة والخطوط والمكونات الأخرى.
|
||||
- **الوصول إلى سطح المكتب البعيد** - دعم RDP و VNC و Telnet عبر المتصفح مع تخصيص كامل وتقسيم الشاشة.
|
||||
- **إدارة أنفاق SSH** - إنشاء وإدارة أنفاق SSH بين الخوادم مع إعادة الاتصال التلقائي ومراقبة الحالة وإعادة التوجيه المحلي أو البعيد أو SOCKS الديناميكي. يتم تخزين إعدادات نفق العميل-المكتبي إلى السيرفر محلياً لكل تثبيت مكتبي؛ يمكن حفظ لقطات C2S الاختيارية على الخادم وإعادة تسميتها وتحميلها أو حذفها لنقل تكوين النفق المحلي بين العملاء.
|
||||
- **مدير الملفات عن بُعد** - إدارة الملفات مباشرة على الخوادم البعيدة مع دعم عرض وتحرير الكود والصور والصوت والفيديو. رفع وتنزيل وإعادة تسمية وحذف ونقل الملفات بسلاسة مع دعم sudo.
|
||||
- **إدارة Docker** - تشغيل وإيقاف وتعليق وحذف الحاويات. عرض إحصائيات الحاويات. التحكم في الحاوية باستخدام طرفية docker exec. لم يُصمم ليحل محل Portainer أو Dockge بل لإدارة حاوياتك ببساطة مقارنة بإنشائها.
|
||||
- **مدير مضيفات SSH** - حفظ وتنظيم وإدارة اتصالات SSH الخاصة بك باستخدام العلامات والمجلدات، وحفظ بيانات تسجيل الدخول القابلة لإعادة الاستخدام بسهولة مع إمكانية أتمتة نشر مفاتيح SSH.
|
||||
- **إحصائيات الخادم** - عرض استخدام المعالج والذاكرة والقرص إلى جانب الشبكة ووقت التشغيل ومعلومات النظام وجدار الحماية ومراقب المنافذ على معظم الخوادم المبنية على Linux.
|
||||
- **لوحة التحكم** - عرض معلومات الخادم بنظرة واحدة على لوحة التحكم.
|
||||
- **RBAC** - إنشاء الأدوار ومشاركة المضيفات عبر المستخدمين/الأدوار.
|
||||
- **مصادقة المستخدمين** - إدارة آمنة للمستخدمين مع ضوابط إدارية ودعم OIDC (مع التحكم في الوصول) و 2FA (TOTP). عرض جلسات المستخدمين النشطة عبر جميع المنصات وإلغاء الصلاحيات. ربط حسابات OIDC/المحلية معاً.
|
||||
- **تشفير قاعدة البيانات** - يُخزَّن الخادم الخلفي كملفات قاعدة بيانات SQLite مشفرة. اطلع على [الوثائق](https://docs.termix.site/security) لمزيد من المعلومات.
|
||||
- **مفاتيح API** - إنشاء مفاتيح API محددة النطاق للمستخدم مع تواريخ انتهاء صلاحية للاستخدام في الأتمتة/CI.
|
||||
- **تصدير/استيراد البيانات** - تصدير واستيراد مضيفات SSH وبيانات الاعتماد وبيانات مدير الملفات.
|
||||
- **إعداد SSL تلقائي** - إنشاء وإدارة شهادات SSL مدمجة مع إعادة التوجيه إلى HTTPS.
|
||||
- **واجهة مستخدم حديثة** - واجهة نظيفة متوافقة مع سطح المكتب والهاتف المحمول مبنية بـ React و Tailwind CSS و Shadcn. الاختيار بين العديد من سمات واجهة المستخدم بما في ذلك الفاتح والداكن و Dracula وغيرها. استخدام مسارات URL لفتح أي اتصال في وضع ملء الشاشة.
|
||||
- **اللغات** - دعم مدمج لحوالي 30 لغة (تُدار بواسطة [Crowdin](https://docs.termix.site/translations)).
|
||||
- **دعم المنصات** - متاح كتطبيق ويب، وتطبيق سطح مكتب (Windows و Linux و macOS، يمكن تشغيله بشكل مستقل بدون خادم Termix الخلفي)، و PWA، وتطبيق مخصص للهاتف المحمول/الجهاز اللوحي لـ iOS و Android.
|
||||
- **أدوات SSH** - إنشاء مقتطفات أوامر قابلة لإعادة الاستخدام تُنفَّذ بنقرة واحدة. تشغيل أمر واحد في وقت واحد عبر عدة طرفيات مفتوحة.
|
||||
- **سجل الأوامر** - الإكمال التلقائي وعرض أوامر SSH التي تم تنفيذها سابقاً.
|
||||
- **الاتصال السريع** - الاتصال بخادم دون الحاجة إلى حفظ بيانات الاتصال.
|
||||
- **لوحة الأوامر** - اضغط مرتين على Shift الأيسر للوصول السريع إلى اتصالات SSH باستخدام لوحة المفاتيح.
|
||||
- **ميزات SSH الغنية** - دعم مضيفات القفز، Warpgate، الاتصالات المبنية على TOTP، SOCKS5، التحقق من مفتاح المضيف، الملء التلقائي لكلمة المرور، [OPKSSH](https://github.com/openpubkey/opkssh)، tmux، port knocking، إلخ.
|
||||
- **الرسم البياني للشبكة** - تخصيص لوحة التحكم لتصور مختبرك المنزلي بناءً على اتصالات SSH مع دعم الحالة.
|
||||
- **علامات التبويب الدائمة** - تبقى جلسات SSH وعلامات التبويب مفتوحة عبر الأجهزة/التحديثات إذا تم تفعيلها في ملف تعريف المستخدم.
|
||||
|
||||
# الميزات المخططة
|
||||
|
||||
@@ -102,7 +87,7 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
قم بزيارة [وثائق](https://docs.termix.site/install) Termix للحصول على مزيد من المعلومات حول كيفية تثبيت Termix على جميع المنصات. بخلاف ذلك، يمكنك الاطلاع على نموذج ملف Docker Compose هنا:
|
||||
قم بزيارة [وثائق](https://docs.termix.site/install) Termix للحصول على مزيد من المعلومات حول كيفية تثبيت Termix على جميع المنصات. بخلاف ذلك، يمكنك الاطلاع على نموذج ملف Docker Compose هنا (يمكنك حذف guacd والشبكة إذا كنت لا تخطط لاستخدام ميزات سطح المكتب البعيد):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -122,7 +107,7 @@ services:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -151,11 +136,23 @@ networks:
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Crowdin">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Crowdin">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
+50
-55
@@ -1,20 +1,7 @@
|
||||
# 仓库统计
|
||||
|
||||
<p align="center">
|
||||
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</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>
|
||||
<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>
|
||||
</p>
|
||||
|
||||

|
||||
@@ -25,7 +12,7 @@
|
||||
<p align="center">
|
||||
<img src="../repo-images/RepoOfTheDay.png" alt="Repo of the Day Achievement" style="width: 300px; height: auto;">
|
||||
<br>
|
||||
<small style="color: #666;">2025年9月1日获得</small>
|
||||
<small style="color: #666;">获得于 2025年9月1日</small>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
@@ -34,9 +21,6 @@
|
||||
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||
</p>
|
||||
|
||||
如果你愿意,可以在这里支持这个项目!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# 概览
|
||||
|
||||
<p align="center">
|
||||
@@ -44,66 +28,66 @@
|
||||
<img alt="Termix Banner" src=../public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||
</p>
|
||||
|
||||
Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个多平台解决方案,通过一个直观的界面管理你的服务器和基础设施。Termix
|
||||
提供 SSH 终端访问、远程桌面控制(RDP、VNC、Telnet)、SSH 隧道功能以及远程文件管理,还会陆续添加更多工具。Termix 是适用于所有平台的完美免费自托管 Termius 替代品。
|
||||
Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个多平台解决方案,通过一个直观的界面管理你的服务器和基础设施。Termix 提供 SSH 终端访问、远程桌面控制(RDP、VNC、Telnet)、SSH 隧道功能、远程 SSH 文件管理以及许多其他工具。Termix 是适用于所有平台的完美免费自托管 Termius 替代品。
|
||||
|
||||
# 功能
|
||||
|
||||
- **SSH 终端访问** - 功能齐全的终端,具有分屏支持(最多 4 个面板)和类似浏览器的选项卡系统。包括对自定义终端的支持,包括常见终端主题、字体和其他组件
|
||||
- **远程桌面访问** - 通过浏览器支持 RDP、VNC 和 Telnet,具有完整的自定义和分屏功能
|
||||
- **SSH 隧道管理** - 创建和管理 SSH 隧道,具有自动重新连接和健康监控功能,支持 -l 或 -r 连接
|
||||
- **远程文件管理器** - 直接在远程服务器上管理文件,支持查看和编辑代码、图像、音频和视频。无缝上传、下载、重命名、删除和移动文件,支持 sudo
|
||||
- **Docker 管理** - 启动、停止、暂停、删除容器。查看容器统计信息。使用 docker exec 终端控制容器。它不是用来替代 Portainer 或 Dockge,而是用于简单管理你的容器而不是创建它们
|
||||
- **SSH 主机管理器** - 保存、组织和管理您的 SSH 连接,支持标签和文件夹,并轻松保存可重用的登录信息,同时能够自动部署 SSH 密钥
|
||||
- **服务器统计** - 在大多数 Linux 服务器上查看 CPU、内存和磁盘使用情况以及网络、正常运行时间、系统信息、防火墙、端口监控
|
||||
- **仪表板** - 在仪表板上一目了然地查看服务器信息
|
||||
- **RBAC** - 创建角色并在用户/角色之间共享主机
|
||||
- **用户认证** - 安全的用户管理,具有管理员控制以及 OIDC 和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地帐户链接在一起
|
||||
- **数据库加密** - 后端存储为加密的 SQLite 数据库文件。查看[文档](https://docs.termix.site/security)了解更多信息
|
||||
- **数据导出/导入** - 导出和导入 SSH 主机、凭据和文件管理器数据
|
||||
- **自动 SSL 设置** - 内置 SSL 证书生成和管理,支持 HTTPS 重定向
|
||||
- **现代用户界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁的桌面/移动设备友好界面。可选择基于深色或浅色模式的用户界面。使用 URL 路由以全屏方式打开任何连接
|
||||
- **语言** - 内置支持约 30 种语言(由 [Crowdin](https://docs.termix.site/translations) 管理)
|
||||
- **平台支持** - 可作为 Web 应用程序、桌面应用程序(Windows、Linux 和 macOS)、PWA 以及适用于 iOS 和 Android 的专用移动/平板电脑应用程序
|
||||
- **SSH 工具** - 创建可重用的命令片段,单击即可执行。在多个打开的终端上同时运行一个命令
|
||||
- **命令历史** - 自动完成并查看以前运行的 SSH 命令
|
||||
- **快速连接** - 无需保存连接数据即可连接到服务器
|
||||
- **命令面板** - 双击左 Shift 键可快速使用键盘访问 SSH 连接
|
||||
- **SSH 功能丰富** - 支持跳板机、Warpgate、基于 TOTP 的连接、SOCKS5、主机密钥验证、密码自动填充、[OPKSSH](https://github.com/openpubkey/opkssh)等
|
||||
- **网络图** - 自定义您的仪表板,根据您的 SSH 连接可视化您的家庭实验室,支持状态显示
|
||||
- **持久标签页** - 如果在用户配置文件中启用,SSH 会话和标签页在设备/刷新后保持打开状态
|
||||
- **SSH 终端访问** - 功能齐全的终端,支持分屏(最多 4 个面板),并配有类似浏览器的标签系统。包括对自定义终端的支持,如常用的终端主题、字体和其他组件。
|
||||
- **远程桌面访问** - 通过浏览器支持 RDP、VNC 和 Telnet,具有完整的自定义和分屏功能。
|
||||
- **SSH 隧道管理** - 创建和管理具有自动重连和健康监测功能的服务器间 SSH 隧道,支持本地、远程或动态 SOCKS 转发。桌面客户端到服务器的隧道设置按桌面安装本地存储,可选的 C2S 预设快照可保存到服务器、重命名、加载或删除,以便在客户端之间迁移本地隧道配置。
|
||||
- **远程文件管理器** - 直接在远程服务器上管理文件,支持查看和编辑代码、图像、音频和视频。支持通过 sudo 无缝上传、下载、重命名、删除和移动文件。
|
||||
- **Docker 管理** - 启动、停止、暂停、移除容器。查看容器统计信息。通过 docker exec 终端控制容器。它的初衷不是取代 Portainer 或 Dockge,而是为了比直接创建容器更简单地管理它们。
|
||||
- **SSH 主机管理器** - 通过标签和文件夹保存、组织和管理您的 SSH 连接,轻松保存可重用的登录信息,并能自动化部署 SSH 密钥。
|
||||
- **服务器统计** - 在大多数基于 Linux 的服务器上查看 CPU、内存、磁盘使用情况以及网络、运行时间、系统信息、防火墙和端口监控。
|
||||
- **仪表板** - 在仪表板上一目了然地查看服务器信息。
|
||||
- **RBAC** - 创建角色并在用户/角色之间共享主机。
|
||||
- **用户认证** - 安全的用户管理,具有管理员控制、OIDC(带访问控制)和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地账户链接在一起。
|
||||
- **数据库加密** - 后端存储为加密的 SQLite 数据库文件。查看[文档](https://docs.termix.site/security)了解更多。
|
||||
- **API 密钥** - 创建带有到期日期的用户范围 API 密钥,用于自动化/CI。
|
||||
- **数据导出/导入** - 导出和导入 SSH 主机、凭据和文件管理器数据。
|
||||
- **自动 SSL 设置** - 内置 SSL 证书生成和管理,支持 HTTPS 重定向。
|
||||
- **现代 UI** - 使用 React、Tailwind CSS 和 Shadcn 构建的整洁的桌面/移动友好界面。有多种 UI 主题可选,包括浅色、深色、Dracula 等。使用 URL 路由全屏打开任何连接。
|
||||
- **语言** - 内置支持约 30 种语言(由 [Crowdin](https://docs.termix.site/translations) 管理)。
|
||||
- **平台支持** - 提供 Web 应用、桌面应用(Windows、Linux 和 macOS,可脱离 Termix 后端独立运行)、PWA 以及 iOS 和 Android 专用移动/平板应用。
|
||||
- **SSH 工具** - 创建可重用的命令片段,只需点击一下即可执行。在多个打开的终端中同时运行一个命令。
|
||||
- **命令历史** - 自动完成并查看之前运行过的 SSH 命令。
|
||||
- **快速连接** - 无需保存连接数据即可连接到服务器。
|
||||
- **命令面板** - 双击左 Shift 键即可通过键盘快速访问 SSH 连接。
|
||||
- **丰富的功能** - 支持跳转主机、Warpgate、基于 TOTP 的连接、SOCKS5、主机密钥验证、密码自动填充、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、端口敲击等。
|
||||
- **网络图** - 自定义您的仪表板,根据您的 SSH 连接可视化您的家庭实验室,并支持状态监测。
|
||||
- **持久标签页** - 如果在用户个人资料中启用,SSH 会话和标签页将在设备/刷新后保持打开状态。
|
||||
|
||||
# 计划功能
|
||||
|
||||
查看 [项目](https://github.com/orgs/Termix-SSH/projects/2) 了解所有计划功能。如果你想贡献代码,请参阅 [贡献指南](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)。
|
||||
查看 [Projects](https://github.com/orgs/Termix-SSH/projects/2) 了解所有计划功能。如果您想贡献代码,请参阅 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)。
|
||||
|
||||
# 安装
|
||||
|
||||
支持的设备:
|
||||
|
||||
- 网站(任何平台上的任何现代浏览器,如 Chrome、Safari 和 Firefox)(包括 PWA 支持)
|
||||
- Windows(x64/ia32)
|
||||
- Windows (x64/ia32)
|
||||
- 便携版
|
||||
- MSI 安装程序
|
||||
- Chocolatey 软件包管理器
|
||||
- Linux(x64/ia32)
|
||||
- Linux (x64/ia32)
|
||||
- 便携版
|
||||
- AUR
|
||||
- AppImage
|
||||
- Deb
|
||||
- Flatpak
|
||||
- macOS(x64/ia32 on v12.0+)
|
||||
- macOS (x64/ia32, v12.0+)
|
||||
- Apple App Store
|
||||
- DMG
|
||||
- Homebrew
|
||||
- iOS/iPadOS(v15.1+)
|
||||
- iOS/iPadOS (v15.1+)
|
||||
- Apple App Store
|
||||
- IPA
|
||||
- Android(v7.0+)
|
||||
- Android (v7.0+)
|
||||
- Google Play 商店
|
||||
- APK
|
||||
|
||||
访问 Termix [文档](https://docs.termix.site/install) 了解有关如何在所有平台上安装 Termix 的更多信息。或者,在此处查看示例 Docker Compose 文件(如果不打算使用远程桌面功能,可以省略 guacd 和 network):
|
||||
访问 Termix [文档](https://docs.termix.site/install) 了解有关如何在所有平台上安装 Termix 的更多信息。此外,这里有一个示例 Docker Compose 文件(如果您不打算使用远程桌面功能,可以省略 guacd 和网络部分):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -123,7 +107,7 @@ services:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -158,13 +142,24 @@ networks:
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# 支持
|
||||
|
||||
如果你需要 Termix 的帮助或想要请求功能,请访问 [Issues](https://github.com/Termix-SSH/Support/issues) 页面,登录并点击 `New Issue`。
|
||||
请尽可能详细地描述你的问题,最好使用英语。你也可以加入 [Discord](https://discord.gg/jVQGdvHDrf) 服务器并访问支持
|
||||
频道,但响应时间可能较长。
|
||||
如果您需要 Termix 的帮助或想要请求功能,请访问 [Issues](https://github.com/Termix-SSH/Support/issues) 页面,登录并点击 `New Issue`。
|
||||
请尽可能详细地描述您的问题,建议使用英语。您也可以加入 [Discord](https://discord.gg/jVQGdvHDrf) 服务器并访问支持频道,但响应时间可能较长。
|
||||
|
||||
# 展示
|
||||
|
||||
@@ -200,7 +195,7 @@ networks:
|
||||
<img src="../repo-images/Image%2012.png" width="400" alt="Termix Demo 12"/>
|
||||
</p>
|
||||
|
||||
某些视频和图像可能已过时或可能无法完美展示功能。
|
||||
某些视频和图像可能已过时,或者可能无法完美展示功能。
|
||||
|
||||
# 许可证
|
||||
|
||||
|
||||
+38
-41
@@ -1,20 +1,7 @@
|
||||
# Repo-Statistiken
|
||||
|
||||
<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-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>
|
||||
<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>
|
||||
</p>
|
||||
|
||||

|
||||
@@ -34,9 +21,6 @@
|
||||
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||
</p>
|
||||
|
||||
Wenn Sie möchten, können Sie das Projekt hier unterstützen!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# Überblick
|
||||
|
||||
<p align="center">
|
||||
@@ -44,33 +28,34 @@ Wenn Sie möchten, können Sie das Projekt hier unterstützen!\
|
||||
<img alt="Termix Banner" src=../public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||
</p>
|
||||
|
||||
Termix ist eine quelloffene, dauerhaft kostenlose, selbst gehostete All-in-One-Serververwaltungsplattform. Sie bietet eine plattformübergreifende Lösung zur Verwaltung Ihrer Server und Infrastruktur über eine einzige, intuitive Oberfläche. Termix bietet SSH-Terminalzugriff, SSH-Tunneling-Funktionen, Remote-Dateiverwaltung und viele weitere Werkzeuge. Termix ist die perfekte kostenlose und selbst gehostete Alternative zu Termius, verfügbar für alle Plattformen.
|
||||
Termix ist eine quelloffene, dauerhaft kostenlose, selbst gehostete All-in-One-Serververwaltungsplattform. Sie bietet eine plattformübergreifende Lösung zur Verwaltung Ihrer Server und Infrastruktur über eine einzige, intuitive Oberfläche. Termix bietet SSH-Terminalzugriff, Remote-Desktop-Steuerung (RDP, VNC, Telnet), SSH-Tunneling-Funktionen, Remote-SSH-Dateiverwaltung und viele weitere Werkzeuge. Termix ist die perfekte kostenlose und selbst gehostete Alternative zu Termius, verfügbar für alle Plattformen.
|
||||
|
||||
# Funktionen
|
||||
|
||||
- **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
|
||||
- **SSH-Tunnelverwaltung** - Erstellen und verwalten Sie SSH-Tunnel mit automatischer Wiederverbindung und Gesundheitsüberwachung sowie Unterstützung für -l oder -r Verbindungen
|
||||
- **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.
|
||||
- **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.
|
||||
- **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
|
||||
- **Serverstatistiken** - CPU-, Arbeitsspeicher- und Festplattenauslastung sowie Netzwerk, Betriebszeit, Systeminformationen, Firewall, Port-Monitor auf den meisten Linux-basierten Servern anzeigen
|
||||
- **Dashboard** - Serverinformationen auf einen Blick auf Ihrem Dashboard anzeigen
|
||||
- **RBAC** - Rollen erstellen und Hosts über Benutzer/Rollen teilen
|
||||
- **Benutzerauthentifizierung** - Sichere Benutzerverwaltung mit Admin-Kontrollen und OIDC- sowie 2FA (TOTP)-Unterstützung. Aktive Benutzersitzungen über alle Plattformen anzeigen und Berechtigungen widerrufen. OIDC-/Lokale Konten miteinander verknüpfen.
|
||||
- **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.
|
||||
- **Serverstatistiken** - CPU-, Arbeitsspeicher- und Festplattenauslastung sowie Netzwerk, Betriebszeit, Systeminformationen, Firewall, Port-Monitor auf den meisten Linux-basierten Servern anzeigen.
|
||||
- **Dashboard** - Serverinformationen auf einen Blick auf Ihrem Dashboard anzeigen.
|
||||
- **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.
|
||||
- **Datenbankverschlüsselung** - Backend gespeichert als verschlüsselte SQLite-Datenbankdateien. Weitere Informationen in der [Dokumentation](https://docs.termix.site/security).
|
||||
- **Datenexport/-import** - SSH-Hosts, Anmeldeinformationen und Dateimanager-Daten exportieren und importieren
|
||||
- **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 dunklem oder hellem Modus. Verwenden Sie URL-Routen, um jede Verbindung im Vollbildmodus zu öffnen.
|
||||
- **Sprachen** - Integrierte Unterstützung für ~30 Sprachen (verwaltet über [Crowdin](https://docs.termix.site/translations))
|
||||
- **Plattformunterstützung** - Verfügbar als Web-App, Desktop-Anwendung (Windows, Linux und macOS), PWA und dedizierte Mobil-/Tablet-App für iOS und Android.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Sprachen** - Integrierte Unterstützung für ~30 Sprachen (verwaltet über [Crowdin](https://docs.termix.site/translations)).
|
||||
- **Plattformunterstützung** - Verfügbar als Web-App, Desktop-Anwendung (Windows, Linux und macOS, kann eigenständig ohne Termix-Backend ausgeführt werden), PWA und dedizierte Mobil-/Tablet-App für iOS und Android.
|
||||
- **SSH-Werkzeuge** - Erstellen Sie wiederverwendbare Befehlsvorlagen, die mit einem einzigen Klick ausgeführt werden. Führen Sie einen Befehl gleichzeitig in mehreren geöffneten Terminals aus.
|
||||
- **Befehlsverlauf** - Autovervollständigung und Anzeige zuvor ausgeführter SSH-Befehle
|
||||
- **Schnellverbindung** - Verbinden Sie sich mit einem Server, ohne die Verbindungsdaten speichern zu müssen
|
||||
- **Befehlspalette** - Doppeltippen Sie die linke Umschalttaste, um schnell auf SSH-Verbindungen mit Ihrer Tastatur zuzugreifen
|
||||
- **SSH-Funktionsreich** - Unterstützt Jump-Hosts, Warpgate, TOTP-basierte Verbindungen, SOCKS5, Host-Key-Verifizierung, automatisches Ausfüllen von Passwörtern, [OPKSSH](https://github.com/openpubkey/opkssh) usw.
|
||||
- **Netzwerkgraph** - Passen Sie Ihr Dashboard an, um Ihr Homelab basierend auf Ihren SSH-Verbindungen mit Statusunterstützung zu visualisieren
|
||||
- **Persistente Tabs** - SSH-Sitzungen und Tabs bleiben über Geräte/Aktualisierungen hinweg offen, wenn im Benutzerprofil aktiviert
|
||||
- **Befehlsverlauf** - Autovervollständigung und Anzeige zuvor ausgeführter SSH-Befehle.
|
||||
- **Schnellverbindung** - Verbinden Sie sich mit einem Server, ohne die Verbindungsdaten speichern zu müssen.
|
||||
- **Befehlspalette** - Doppeltippen Sie die linke Umschalttaste, um schnell auf SSH-Verbindungen mit Ihrer Tastatur zuzugreifen.
|
||||
- **SSH-Funktionsreich** - Unterstützt Jump-Hosts, Warpgate, TOTP-basierte Verbindungen, SOCKS5, Host-Key-Verifizierung, automatisches Ausfüllen von Passwörtern, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, Port Knocking usw.
|
||||
- **Netzwerkgraph** - Passen Sie Ihr Dashboard an, um Ihr Homelab basierend auf Ihren SSH-Verbindungen mit Statusunterstützung zu visualisieren.
|
||||
- **Persistente Tabs** - SSH-Sitzungen und Tabs bleiben über Geräte/Aktualisierungen hinweg offen, wenn im Benutzerprofil aktiviert.
|
||||
|
||||
# Geplante Funktionen
|
||||
|
||||
@@ -102,7 +87,7 @@ Unterstützte Geräte:
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
Besuchen Sie die Termix-[Dokumentation](https://docs.termix.site/install) für weitere Informationen zur Installation von Termix auf allen Plattformen. Alternativ finden Sie hier eine Docker Compose-Beispieldatei:
|
||||
Besuchen Sie die Termix-[Dokumentation](https://docs.termix.site/install) für weitere Informationen zur Installation von Termix auf allen Plattformen. Alternativ finden Sie hier eine Docker Compose-Beispieldatei (Sie können guacd und das Netzwerk weglassen, wenn Sie keine Remote-Desktop-Funktionen nutzen möchten):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -122,7 +107,7 @@ services:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -151,11 +136,23 @@ networks:
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Crowdin">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Crowdin">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
+39
-42
@@ -1,20 +1,7 @@
|
||||
# Estadísticas del Repositorio
|
||||
|
||||
<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-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>
|
||||
<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>
|
||||
</p>
|
||||
|
||||

|
||||
@@ -34,9 +21,6 @@
|
||||
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||
</p>
|
||||
|
||||
Si lo desea, puede apoyar el proyecto aquí.\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# Descripción General
|
||||
|
||||
<p align="center">
|
||||
@@ -44,33 +28,34 @@ Si lo desea, puede apoyar el proyecto aquí.\
|
||||
<img alt="Termix Banner" src=../public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||
</p>
|
||||
|
||||
Termix es una plataforma de gestión de servidores todo en uno, de código abierto, siempre gratuita y autoalojada. Proporciona una solución multiplataforma para gestionar sus servidores e infraestructura a través de una interfaz única e intuitiva. Termix ofrece acceso a terminal SSH, capacidades de túneles SSH, gestión remota de archivos y muchas otras herramientas. Termix es la alternativa perfecta, gratuita y autoalojada a Termius, disponible para todas las plataformas.
|
||||
Termix es una plataforma de gestión de servidores todo en uno, de código abierto, siempre gratuita y autoalojada. Proporciona una solución multiplataforma para gestionar sus servidores e infraestructura a través de una interfaz única e intuitiva. Termix ofrece acceso a terminal SSH, control de escritorio remoto (RDP, VNC, Telnet), capacidades de túneles SSH, gestión remota de archivos SSH y muchas otras herramientas. Termix es la alternativa perfecta, gratuita y autoalojada a Termius, disponible para todas las plataformas.
|
||||
|
||||
# Características
|
||||
|
||||
- **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
|
||||
- **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
|
||||
- **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.
|
||||
- **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.
|
||||
- **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
|
||||
- **Estadísticas del Servidor** - Vea el uso de CPU, memoria y disco junto con red, tiempo de actividad, información del sistema, firewall, monitor de puertos en la mayoría de los servidores basados en Linux
|
||||
- **Dashboard** - Vea la información del servidor de un vistazo en su dashboard
|
||||
- **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 y 2FA (TOTP). Vea sesiones activas de usuarios en todas las plataformas y revoque permisos. Vincule sus cuentas OIDC/Locales entre sí.
|
||||
- **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.
|
||||
- **Estadísticas del Servidor** - Vea el uso de CPU, memoria y disco junto con red, tiempo de actividad, información del sistema, firewall, monitor de puertos en la mayoría de los servidores basados en Linux.
|
||||
- **Dashboard** - Vea la información del servidor de un vistazo en su dashboard.
|
||||
- **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í.
|
||||
- **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.
|
||||
- **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
|
||||
- **Interfaz Moderna** - Interfaz limpia compatible con escritorio/móvil construida con React, Tailwind CSS y Shadcn. Elija entre modo oscuro o claro. Use rutas URL para abrir cualquier conexión en pantalla completa.
|
||||
- **Idiomas** - Soporte integrado para ~30 idiomas (gestionado por [Crowdin](https://docs.termix.site/translations))
|
||||
- **Soporte de Plataformas** - Disponible como aplicación web, aplicación de escritorio (Windows, Linux y macOS), PWA y aplicación dedicada para móviles/tablets en iOS y Android.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Idiomas** - Soporte integrado para ~30 idiomas (gestionado por [Crowdin](https://docs.termix.site/translations)).
|
||||
- **Soporte de Plataformas** - Disponible como aplicación web, aplicación de escritorio (Windows, Linux y macOS, se puede ejecutar de forma independiente sin el backend de Termix), PWA y aplicación dedicada para móviles/tablets en iOS y Android.
|
||||
- **Herramientas SSH** - Cree fragmentos de comandos reutilizables que se ejecutan con un solo clic. Ejecute un comando simultáneamente en múltiples terminales abiertos.
|
||||
- **Historial de Comandos** - Autocompletado y visualización de comandos SSH ejecutados anteriormente
|
||||
- **Conexión Rápida** - Conéctese a un servidor sin necesidad de guardar los datos de conexión
|
||||
- **Paleta de Comandos** - Pulse dos veces la tecla Shift izquierda para acceder rápidamente a las conexiones SSH con su teclado
|
||||
- **SSH Rico en Funciones** - Soporta jump hosts, Warpgate, conexiones basadas en TOTP, SOCKS5, verificación de clave de host, autocompletado de contraseñas, [OPKSSH](https://github.com/openpubkey/opkssh), etc.
|
||||
- **Gráfico de Red** - Personalice su Dashboard para visualizar su homelab basado en sus conexiones SSH con soporte de estado
|
||||
- **Pestañas Persistentes** - Las sesiones SSH y pestañas permanecen abiertas entre dispositivos/actualizaciones si está habilitado en el perfil de usuario
|
||||
- **Historial de Comandos** - Autocompletado y visualización de comandos SSH ejecutados anteriormente.
|
||||
- **Conexión Rápida** - Conéctese a un servidor sin necesidad de guardar los datos de conexión.
|
||||
- **Paleta de Comandos** - Pulse dos veces la tecla Shift izquierda para acceder rápidamente a las conexiones SSH con su teclado.
|
||||
- **SSH Rico en Funciones** - Soporta jump hosts, Warpgate, conexiones basadas en TOTP, SOCKS5, verificación de clave de host, autocompletado de contraseñas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc.
|
||||
- **Gráfico de Red** - Personalice su Dashboard para visualizar su homelab basado en sus conexiones SSH con soporte de estado.
|
||||
- **Pestañas Persistentes** - Las sesiones SSH y pestañas permanecen abiertas entre dispositivos/actualizaciones si está habilitado en el perfil de usuario.
|
||||
|
||||
# Características Planeadas
|
||||
|
||||
@@ -102,7 +87,7 @@ Dispositivos soportados:
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
Visite la [documentación](https://docs.termix.site/install) de Termix para más información sobre cómo instalar Termix en todas las plataformas. De lo contrario, vea un archivo Docker Compose de ejemplo aquí:
|
||||
Visite la [documentación](https://docs.termix.site/install) de Termix para más información sobre cómo instalar Termix en todas las plataformas. De lo contrario, vea un archivo Docker Compose de ejemplo aquí (puede omitir guacd y la red si no planea usar funciones de escritorio remoto):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -122,7 +107,7 @@ services:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -151,11 +136,23 @@ networks:
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Crowdin">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Crowdin">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
+42
-45
@@ -1,20 +1,7 @@
|
||||
# Statistiques du dépôt
|
||||
|
||||
<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-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>
|
||||
<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>
|
||||
</p>
|
||||
|
||||

|
||||
@@ -34,9 +21,6 @@
|
||||
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||
</p>
|
||||
|
||||
Si vous le souhaitez, vous pouvez soutenir le projet ici !\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# Présentation
|
||||
|
||||
<p align="center">
|
||||
@@ -44,33 +28,34 @@ Si vous le souhaitez, vous pouvez soutenir le projet ici !\
|
||||
<img alt="Termix Banner" src=../public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||
</p>
|
||||
|
||||
Termix est une plateforme de gestion de serveurs tout-en-un, open source, à jamais gratuite et auto-hébergée. Elle fournit une solution multiplateforme pour gérer vos serveurs et votre infrastructure à travers une interface unique et intuitive. Termix offre un accès terminal SSH, des capacités de tunneling SSH, la gestion de fichiers à distance, et de nombreux autres outils. Termix est l'alternative parfaite, gratuite et auto-hébergée à Termius, disponible sur toutes les plateformes.
|
||||
Termix est une plateforme de gestion de serveurs tout-en-un, open source, à jamais gratuite et auto-hébergée. Elle fournit une solution multiplateforme pour gérer vos serveurs et votre infrastructure à travers une interface unique et intuitive. Termix offre un accès terminal SSH, le contrôle de bureau à distance (RDP, VNC, Telnet), des capacités de tunneling SSH, la gestion de fichiers SSH à distance et de nombreux autres outils. Termix est l'alternative parfaite, gratuite et auto-hébergée à Termius, disponible sur toutes les plateformes.
|
||||
|
||||
# Fonctionnalités
|
||||
|
||||
- **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é
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **Statistiques serveur** - Visualisez l'utilisation du CPU, de la mémoire et du disque ainsi que le réseau, le temps de fonctionnement, les informations système, le pare-feu et le moniteur de ports sur la plupart des serveurs Linux
|
||||
- **Tableau de bord** - Consultez les informations de vos serveurs en un coup d'œil depuis votre tableau de bord
|
||||
- **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 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
|
||||
- **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
|
||||
- **Interface moderne** - Interface épurée compatible desktop/mobile construite avec React, Tailwind CSS et Shadcn. Choisissez entre un thème sombre ou clair. Utilisez les routes URL pour ouvrir n'importe quelle connexion en plein écran
|
||||
- **Langues** - Support intégré d'environ 30 langues (géré par [Crowdin](https://docs.termix.site/translations))
|
||||
- **Support multiplateforme** - Disponible en tant qu'application web, application de bureau (Windows, Linux et macOS), PWA, et application mobile/tablette dédiée pour iOS et Android
|
||||
- **Outils SSH** - Créez des extraits de commandes réutilisables exécutables en un seul clic. Exécutez une commande simultanément sur plusieurs terminaux ouverts
|
||||
- **Historique des commandes** - Auto-complétion et consultation des commandes SSH précédemment exécutées
|
||||
- **Connexion rapide** - Connectez-vous à un serveur sans avoir à sauvegarder les données de connexion
|
||||
- **Palette de commandes** - Appuyez deux fois sur Shift gauche pour accéder rapidement aux connexions SSH avec votre clavier
|
||||
- **SSH riche en fonctionnalités** - Support des hôtes de rebond, Warpgate, connexions basées sur TOTP, SOCKS5, vérification des clés d'hôte, remplissage automatique des mots de passe, [OPKSSH](https://github.com/openpubkey/opkssh), etc.
|
||||
- **Graphe réseau** - Personnalisez votre tableau de bord pour visualiser votre homelab basé sur vos connexions SSH avec support des statuts
|
||||
- **Onglets Persistants** - Les sessions SSH et les onglets restent ouverts sur tous les appareils/actualisations si activé dans le profil utilisateur
|
||||
- **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é.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Statistiques serveur** - Visualisez l'utilisation du CPU, de la mémoire et du disque ainsi que le réseau, le temps de fonctionnement, les informations système, le pare-feu et le moniteur de ports sur la plupart des serveurs Linux.
|
||||
- **Tableau de bord** - Consultez les informations de vos serveurs en un coup d'œil depuis votre tableau de bord.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Langues** - Support intégré d'environ 30 langues (géré par [Crowdin](https://docs.termix.site/translations)).
|
||||
- **Support multiplateforme** - Disponible en tant qu'application web, application de bureau (Windows, Linux et macOS, peut être exécutée de manière autonome sans backend Termix), PWA, et application mobile/tablette dédiée pour iOS et Android.
|
||||
- **Outils SSH** - Créez des extraits de commandes réutilisables exécutables en un seul clic. Exécutez une commande simultanément sur plusieurs terminaux ouverts.
|
||||
- **Historique des commandes** - Auto-complétion et consultation des commandes SSH précédemment exécutées.
|
||||
- **Connexion rapide** - Connectez-vous à un serveur sans avoir à sauvegarder les données de connexion.
|
||||
- **Palette de commandes** - Appuyez deux fois sur Shift gauche pour accéder rapidement aux connexions SSH avec votre clavier.
|
||||
- **SSH riche en fonctionnalités** - Support des hôtes de rebond, Warpgate, connexions basées sur TOTP, SOCKS5, vérification des clés d'hôte, remplissage automatique des mots de passe, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc.
|
||||
- **Graphe réseau** - Personnalisez votre tableau de bord pour visualiser votre homelab basé sur vos connexions SSH avec support des statuts.
|
||||
- **Onglets Persistants** - Les sessions SSH et les onglets restent ouverts sur tous les appareils/actualisations si activé dans le profil utilisateur.
|
||||
|
||||
# Fonctionnalités prévues
|
||||
|
||||
@@ -102,7 +87,7 @@ Appareils supportés :
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
Visitez la [documentation](https://docs.termix.site/install) de Termix pour plus d'informations sur l'installation de Termix sur toutes les plateformes. Sinon, voici un exemple de fichier Docker Compose :
|
||||
Visitez la [documentation](https://docs.termix.site/install) de Termix pour plus d'informations sur l'installation de Termix sur toutes les plateformes. Sinon, voici un exemple de fichier Docker Compose (vous pouvez omettre guacd et le réseau si vous ne prévoyez pas d'utiliser les fonctionnalités de bureau à distance) :
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -122,7 +107,7 @@ services:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -151,11 +136,23 @@ networks:
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Crowdin">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Crowdin">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
+42
-45
@@ -1,20 +1,7 @@
|
||||
# रिपॉजिटरी आँकड़े
|
||||
|
||||
<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-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>
|
||||
<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>
|
||||
</p>
|
||||
|
||||

|
||||
@@ -34,9 +21,6 @@
|
||||
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||
</p>
|
||||
|
||||
यदि आप चाहें, तो आप यहाँ प्रोजेक्ट को सपोर्ट कर सकते हैं!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# अवलोकन
|
||||
|
||||
<p align="center">
|
||||
@@ -44,33 +28,34 @@
|
||||
<img alt="Termix Banner" src=../public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||
</p>
|
||||
|
||||
Termix एक ओपन-सोर्स, हमेशा के लिए मुफ़्त, सेल्फ-होस्टेड ऑल-इन-वन सर्वर प्रबंधन प्लेटफ़ॉर्म है। यह एक एकल, सहज इंटरफ़ेस के माध्यम से आपके सर्वर और बुनियादी ढाँचे के प्रबंधन के लिए एक मल्टी-प्लेटफ़ॉर्म समाधान प्रदान करता है। Termix SSH टर्मिनल एक्सेस, SSH टनलिंग क्षमताएँ, रिमोट फ़ाइल प्रबंधन, और कई अन्य उपकरण प्रदान करता है। Termix सभी प्लेटफ़ॉर्म पर उपलब्ध Termius का सही मुफ़्त और सेल्फ-होस्टेड विकल्प है।
|
||||
Termix एक ओपन-सोर्स, हमेशा के लिए मुफ़्त, सेल्फ-होस्टेड ऑल-इन-वन सर्वर प्रबंधन प्लेटफ़ॉर्म है। यह एक एकल, सहज इंटरफ़ेस के माध्यम से आपके सर्वर और बुनियादी ढाँचे के प्रबंधन के लिए एक मल्टी-प्लेटफ़ॉर्म समाधान प्रदान करता है। Termix SSH टर्मिनल एक्सेस, रिमोट डेस्कटॉप कंट्रोल (RDP, VNC, Telnet), SSH टनलिंग क्षमताएँ, रिमोट SSH फ़ाइल प्रबंधन, और कई अन्य उपकरण प्रदान करता है। Termix सभी प्लेटफ़ॉर्म पर उपलब्ध Termius का सही मुफ़्त और सेल्फ-होस्टेड विकल्प है।
|
||||
|
||||
# विशेषताएँ
|
||||
|
||||
- **SSH टर्मिनल एक्सेस** - ब्राउज़र जैसी टैब प्रणाली के साथ स्प्लिट-स्क्रीन सपोर्ट (4 पैनल तक) वाला पूर्ण-विशेषता वाला टर्मिनल। इसमें लोकप्रिय टर्मिनल थीम, फ़ॉन्ट और अन्य कंपोनेंट सहित टर्मिनल को कस्टमाइज़ करने का सपोर्ट शामिल है
|
||||
- **रिमोट डेस्कटॉप एक्सेस** - ब्राउज़र पर RDP, VNC और Telnet सपोर्ट, पूर्ण कस्टमाइज़ेशन और स्प्लिट स्क्रीन के साथ
|
||||
- **SSH टनल प्रबंधन** - ऑटोमैटिक रीकनेक्शन और हेल्थ मॉनिटरिंग के साथ SSH टनल बनाएँ और प्रबंधित करें, -l या -r कनेक्शन के सपोर्ट के साथ
|
||||
- **रिमोट फ़ाइल मैनेजर** - कोड, इमेज, ऑडियो और वीडियो देखने और संपादित करने के सपोर्ट के साथ रिमोट सर्वर पर सीधे फ़ाइलें प्रबंधित करें। sudo सपोर्ट के साथ फ़ाइलें अपलोड, डाउनलोड, रीनेम, डिलीट और मूव करें
|
||||
- **Docker प्रबंधन** - कंटेनर शुरू, बंद, पॉज़, हटाएँ। कंटेनर स्टैट्स देखें। docker exec टर्मिनल का उपयोग करके कंटेनर को नियंत्रित करें। इसे Portainer या Dockge की जगह लेने के लिए नहीं बनाया गया बल्कि कंटेनर बनाने की तुलना में उन्हें सरलता से प्रबंधित करने के लिए बनाया गया है
|
||||
- **SSH होस्ट मैनेजर** - टैग और फ़ोल्डर के साथ अपने SSH कनेक्शन सहेजें, व्यवस्थित करें और प्रबंधित करें, और SSH कुंजियों की तैनाती को स्वचालित करने की क्षमता के साथ पुन: उपयोग योग्य लॉगिन जानकारी आसानी से सहेजें
|
||||
- **सर्वर आँकड़े** - अधिकांश Linux आधारित सर्वर पर नेटवर्क, अपटाइम, सिस्टम जानकारी, फ़ायरवॉल, पोर्ट मॉनिटर के साथ CPU, मेमोरी और डिस्क उपयोग देखें
|
||||
- **डैशबोर्ड** - अपने डैशबोर्ड पर एक नज़र में सर्वर की जानकारी देखें
|
||||
- **RBAC** - भूमिकाएँ बनाएँ और उपयोगकर्ताओं/भूमिकाओं में होस्ट साझा करें
|
||||
- **उपयोगकर्ता प्रमाणीकरण** - व्यवस्थापक नियंत्रण और OIDC और 2FA (TOTP) सपोर्ट के साथ सुरक्षित उपयोगकर्ता प्रबंधन। सभी प्लेटफ़ॉर्म पर सक्रिय उपयोगकर्ता सत्र देखें और अनुमतियाँ रद्द करें। अपने OIDC/स्थानीय खातों को एक साथ जोड़ें
|
||||
- **डेटाबेस एन्क्रिप्शन** - बैकएंड एन्क्रिप्टेड SQLite डेटाबेस फ़ाइलों के रूप में संग्रहीत। अधिक जानकारी के लिए [डॉक्स](https://docs.termix.site/security) देखें
|
||||
- **डेटा एक्सपोर्ट/इम्पोर्ट** - SSH होस्ट, क्रेडेंशियल और फ़ाइल मैनेजर डेटा एक्सपोर्ट और इम्पोर्ट करें
|
||||
- **स्वचालित SSL सेटअप** - HTTPS रीडायरेक्ट के साथ बिल्ट-इन SSL सर्टिफ़िकेट जनरेशन और प्रबंधन
|
||||
- **आधुनिक UI** - React, Tailwind CSS, और Shadcn से बना साफ़ डेस्कटॉप/मोबाइल-फ़्रेंडली इंटरफ़ेस। डार्क या लाइट मोड UI के बीच चुनें। किसी भी कनेक्शन को फ़ुल-स्क्रीन में खोलने के लिए URL रूट का उपयोग करें
|
||||
- **भाषाएँ** - लगभग 30 भाषाओं का बिल्ट-इन सपोर्ट ([Crowdin](https://docs.termix.site/translations) द्वारा प्रबंधित)
|
||||
- **प्लेटफ़ॉर्म सपोर्ट** - वेब ऐप, डेस्कटॉप एप्लिकेशन (Windows, Linux, और macOS), PWA, और iOS और Android के लिए समर्पित मोबाइल/टैबलेट ऐप के रूप में उपलब्ध
|
||||
- **SSH टूल्स** - एक क्लिक से निष्पादित होने वाले पुन: उपयोग योग्य कमांड स्निपेट बनाएँ। एक साथ कई खुले टर्मिनलों में एक कमांड चलाएँ
|
||||
- **कमांड इतिहास** - पहले चलाए गए SSH कमांड का ऑटो-कम्प्लीट और दृश्य
|
||||
- **क्विक कनेक्ट** - कनेक्शन डेटा सहेजे बिना सर्वर से कनेक्ट करें
|
||||
- **कमांड पैलेट** - अपने कीबोर्ड से SSH कनेक्शन तक त्वरित पहुँच के लिए बाएँ Shift को दो बार टैप करें
|
||||
- **SSH सुविधाओं से भरपूर** - जम्प होस्ट, Warpgate, TOTP आधारित कनेक्शन, SOCKS5, होस्ट की वेरिफ़िकेशन, पासवर्ड ऑटोफ़िल, [OPKSSH](https://github.com/openpubkey/opkssh) आदि का सपोर्ट
|
||||
- **नेटवर्क ग्राफ़** - स्थिति सपोर्ट के साथ अपने SSH कनेक्शन के आधार पर अपने होमलैब को विज़ुअलाइज़ करने के लिए अपना डैशबोर्ड कस्टमाइज़ करें
|
||||
- **परसिस्टेंट टैब** - उपयोगकर्ता प्रोफ़ाइल में सक्षम होने पर SSH सेशन और टैब डिवाइस/रीफ्रेश के पार खुले रहते हैं
|
||||
- **SSH टर्मिनल एक्सेस** - ब्राउज़र जैसी टैब प्रणाली के साथ स्प्लिट-स्क्रीन सपोर्ट (4 पैनल तक) वाला पूर्ण-विशेषता वाला टर्मिनल। इसमें लोकप्रिय टर्मिनल थीम, फ़ॉन्ट और अन्य कंपोनेंट सहित टर्मिनल को कस्टमाइज़ करने का सपोर्ट शामिल है।
|
||||
- **रिमोट डेस्कटॉप एक्सेस** - ब्राउज़र पर RDP, VNC और Telnet सपोर्ट, पूर्ण कस्टमाइज़ेशन और स्प्लिट स्क्रीन के साथ।
|
||||
- **SSH टनल प्रबंधन** - ऑटोमैटिक रीकनेक्शन, हेल्थ मॉनिटरिंग और लोकल, रिमोट या डायनेमिक SOCKS फॉरवर्डिंग के साथ सर्वर-टु-सर्वर SSH टनल बनाएँ और प्रबंधित करें। डेस्कटॉप क्लाइंट-टु-सर्वर टनल सेटिंग्स प्रत्येक डेस्कटॉप इंस्टॉल में स्थानीय रूप से संग्रहीत होती हैं; वैकल्पिक C2S प्रीसेट स्नैपशॉट सर्वर पर सेव, रीनेम, लोड या डिलीट किए जा सकते हैं।
|
||||
- **रिमोट फ़ाइल मैनेजर** - कोड, इमेज, ऑडियो और वीडियो देखने और संपादित करने के सपोर्ट के साथ रिमोट सर्वर पर सीधे फ़ाइलें प्रबंधित करें। sudo सपोर्ट के साथ फ़ाइलें अपलोड, डाउनलोड, रीनेम, डिलीट और मूव करें।
|
||||
- **Docker प्रबंधन** - कंटेनर शुरू, बंद, पॉज़, हटाएँ। कंटेनर स्टैट्स देखें। docker exec टर्मिनल का उपयोग करके कंटेनर को नियंत्रित करें। इसे Portainer या Dockge की जगह लेने के लिए नहीं बनाया गया बल्कि कंटेनर बनाने की तुलना में उन्हें सरलता से प्रबंधित करने के लिए बनाया गया है।
|
||||
- **SSH होस्ट मैनेजर** - टैग और फ़ोल्डर के साथ अपने SSH कनेक्शन सहेजें, व्यवस्थित करें और प्रबंधित करें, और SSH कुंजियों की तैनाती को स्वचालित करने की क्षमता के साथ पुन: उपयोग योग्य लॉगिन जानकारी आसानी से सहेजें।
|
||||
- **सर्वर आँकड़े** - अधिकांश Linux आधारित सर्वर पर नेटवर्क, अपटाइम, सिस्टम जानकारी, फ़ायरवॉल, पोर्ट मॉनिटर के साथ CPU, मेमोरी और डिस्क उपयोग देखें।
|
||||
- **डैशबोर्ड** - अपने डैशबोर्ड पर एक नज़र में सर्वर की जानकारी देखें।
|
||||
- **RBAC** - भूमिकाएँ बनाएँ और उपयोगकर्ताओं/भूमिकाओं में होस्ट साझा करें।
|
||||
- **उपयोगकर्ता प्रमाणीकरण** - व्यवस्थापक नियंत्रण और OIDC (एक्सेस कंट्रोल के साथ) और 2FA (TOTP) सपोर्ट के साथ सुरक्षित उपयोगकर्ता प्रबंधन। सभी प्लेटफ़ॉर्म पर सक्रिय उपयोगकर्ता सत्र देखें और अनुमतियाँ रद्द करें। अपने OIDC/स्थानीय खातों को एक साथ जोड़ें।
|
||||
- **डेटाबेस एन्क्रिप्शन** - बैकएंड एन्क्रिप्टेड SQLite डेटाबेस फ़ाइलों के रूप में संग्रहीत। अधिक जानकारी के लिए [डॉक्स](https://docs.termix.site/security) देखें।
|
||||
- **API कुंजियाँ** - ऑटोमेशन/CI के लिए उपयोग हेतु समाप्ति तिथियों के साथ उपयोगकर्ता-स्कोप्ड API कुंजियाँ बनाएँ।
|
||||
- **डेटा एक्सपोर्ट/इम्पोर्ट** - SSH होस्ट, क्रेडेंशियल और फ़ाइल मैनेजर डेटा एक्सपोर्ट और इम्पोर्ट करें।
|
||||
- **स्वचालित SSL सेटअप** - HTTPS रीडायरेक्ट के साथ बिल्ट-इन SSL सर्टिफ़िकेट जनरेशन और प्रबंधन।
|
||||
- **आधुनिक UI** - React, Tailwind CSS, और Shadcn से बना साफ़ डेस्कटॉप/मोबाइल-फ़्रेंडली इंटरफ़ेस। लाइट, डार्क, ड्रैकुला आदि सहित कई अलग-अलग UI थीम के बीच चुनें। किसी भी कनेक्शन को फ़ुल-स्क्रीन में खोलने के लिए URL रूट का उपयोग करें।
|
||||
- **भाषाएँ** - लगभग 30 भाषाओं का बिल्ट-इन सपोर्ट ([Crowdin](https://docs.termix.site/translations) द्वारा प्रबंधित)।
|
||||
- **प्लेटफ़ॉर्म सपोर्ट** - वेब ऐप, डेस्कटॉप एप्लिकेशन (Windows, Linux, और macOS, Termix बैकएंड के बिना स्टैंडअलोन चलाया जा सकता है), PWA, और iOS और Android के लिए समर्पित मोबाइल/टैबलेट ऐप के रूप में उपलब्ध।
|
||||
- **SSH टूल्स** - एक क्लिक से निष्पादित होने वाले पुन: उपयोग योग्य कमांड स्निपेट बनाएँ। एक साथ कई खुले टर्मिनलों में एक कमांड चलाएँ।
|
||||
- **कमांड इतिहास** - पहले चलाए गए SSH कमांड का ऑटो-कम्प्लीट और दृश्य।
|
||||
- **क्विक कनेक्ट** - कनेक्शन डेटा सहेजे बिना सर्वर से कनेक्ट करें।
|
||||
- **कमांड पैलेट** - अपने कीबोर्ड से SSH कनेक्शन तक त्वरित पहुँच के लिए बाएँ Shift को दो बार टैप करें।
|
||||
- **SSH सुविधाओं से भरपूर** - जम्प होस्ट, Warpgate, TOTP आधारित कनेक्शन, SOCKS5, होस्ट की वेरिफ़िकेशन, पासवर्ड ऑटोफ़िल, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, पोर्ट नॉकिंग आदि का सपोर्ट।
|
||||
- **नेटवर्क ग्राफ़** - स्थिति सपोर्ट के साथ अपने SSH कनेक्शन के आधार पर अपने होमलैब को विज़ुअलाइज़ करने के लिए अपना डैशबोर्ड कस्टमाइज़ करें।
|
||||
- **परसिस्टेंट टैब** - उपयोगकर्ता प्रोफ़ाइल में सक्षम होने पर SSH सेशन और टैब डिवाइस/रीफ्रेश के पार खुले रहते हैं।
|
||||
|
||||
# नियोजित विशेषताएँ
|
||||
|
||||
@@ -102,7 +87,7 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
सभी प्लेटफ़ॉर्म पर Termix इंस्टॉल करने के बारे में अधिक जानकारी के लिए Termix [डॉक्स](https://docs.termix.site/install) पर जाएँ। अन्यथा, यहाँ एक नमूना Docker Compose फ़ाइल देखें:
|
||||
सभी प्लेटफ़ॉर्म पर Termix इंस्टॉल करने के बारे में अधिक जानकारी के लिए Termix [डॉक्स](https://docs.termix.site/install) पर जाएँ। अन्यथा, यहाँ एक नमूना Docker Compose फ़ाइल देखें (यदि आप रिमोट डेस्कटॉप सुविधाओं का उपयोग करने की योजना नहीं बना रहे हैं तो आप guacd और नेटवर्क को हटा सकते हैं):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -122,7 +107,7 @@ services:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -151,11 +136,23 @@ networks:
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Crowdin">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Crowdin">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
+39
-42
@@ -1,20 +1,7 @@
|
||||
# Statistiche Repo
|
||||
|
||||
<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-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
|
||||
<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
|
||||
</p>
|
||||
|
||||

|
||||
@@ -34,9 +21,6 @@
|
||||
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||
</p>
|
||||
|
||||
Se lo desideri, puoi supportare il progetto qui!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# Panoramica
|
||||
|
||||
<p align="center">
|
||||
@@ -44,33 +28,34 @@ Se lo desideri, puoi supportare il progetto qui!\
|
||||
<img alt="Termix Banner" src=../public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||
</p>
|
||||
|
||||
Termix è una piattaforma di gestione server tutto-in-uno, open-source, per sempre gratuita e self-hosted. Fornisce una soluzione multipiattaforma per gestire i tuoi server e la tua infrastruttura attraverso un'unica interfaccia intuitiva. Termix offre accesso al terminale SSH, funzionalità di tunneling SSH, gestione remota dei file e molti altri strumenti. Termix è la perfetta alternativa gratuita e self-hosted a Termius, disponibile per tutte le piattaforme.
|
||||
Termix è una piattaforma di gestione server tutto-in-uno, open-source, per sempre gratuita e self-hosted. Fornisce una soluzione multipiattaforma per gestire i tuoi server e la tua infrastruttura attraverso un'unica interfaccia intuitiva. Termix offre accesso al terminale SSH, controllo remoto del desktop (RDP, VNC, Telnet), funzionalità di tunneling SSH, gestione remota dei file SSH e molti altri strumenti. Termix è la perfetta alternativa gratuita e self-hosted a Termius, disponibile per tutte le piattaforme.
|
||||
|
||||
# Funzionalità
|
||||
|
||||
- **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
|
||||
- **Gestione Tunnel SSH** - Crea e gestisci tunnel SSH con riconnessione automatica e monitoraggio dello stato, con supporto per connessioni -l o -r
|
||||
- **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.
|
||||
- **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.
|
||||
- **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
|
||||
- **Statistiche Server** - Visualizza l'utilizzo di CPU, memoria e disco insieme a rete, uptime, informazioni di sistema, firewall, monitoraggio porte sulla maggior parte dei server basati su Linux
|
||||
- **Dashboard** - Visualizza le informazioni del server a colpo d'occhio sulla tua dashboard
|
||||
- **RBAC** - Crea ruoli e condividi host tra utenti/ruoli
|
||||
- **Autenticazione Utente** - Gestione utenti sicura con controlli amministrativi e supporto OIDC e 2FA (TOTP). Visualizza le sessioni utente attive su tutte le piattaforme e revoca i permessi. Collega i tuoi account OIDC/Locali tra loro.
|
||||
- **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.
|
||||
- **Statistiche Server** - Visualizza l'utilizzo di CPU, memoria e disco insieme a rete, uptime, informazioni di sistema, firewall, monitoraggio porte sulla maggior parte dei server basati su Linux.
|
||||
- **Dashboard** - Visualizza le informazioni del server a colpo d'occhio sulla tua dashboard.
|
||||
- **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.
|
||||
- **Crittografia Database** - Il backend è archiviato come file di database SQLite crittografati. Consulta la [documentazione](https://docs.termix.site/security) per maggiori informazioni.
|
||||
- **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
|
||||
- **Interfaccia Moderna** - Interfaccia pulita e responsive per desktop/mobile costruita con React, Tailwind CSS e Shadcn. Scegli tra modalità scura o chiara. Usa i percorsi URL per aprire qualsiasi connessione a schermo intero.
|
||||
- **Lingue** - Supporto integrato per ~30 lingue (gestito da [Crowdin](https://docs.termix.site/translations))
|
||||
- **Supporto Piattaforme** - Disponibile come app web, applicazione desktop (Windows, Linux e macOS), PWA e app dedicata per mobile/tablet su iOS e Android.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Lingue** - Supporto integrato per ~30 lingue (gestito da [Crowdin](https://docs.termix.site/translations)).
|
||||
- **Supporto Piattaforme** - Disponibile come app web, applicazione desktop (Windows, Linux e macOS, può essere eseguito in modo autonomo senza il backend Termix), PWA e app dedicata per mobile/tablet su iOS e Android.
|
||||
- **Strumenti SSH** - Crea snippet di comandi riutilizzabili che si eseguono con un singolo clic. Esegui un comando simultaneamente su più terminali aperti.
|
||||
- **Cronologia Comandi** - Autocompletamento e visualizzazione dei comandi SSH eseguiti in precedenza
|
||||
- **Connessione Rapida** - Connettiti a un server senza dover salvare i dati di connessione
|
||||
- **Palette Comandi** - Premi due volte shift sinistro per accedere rapidamente alle connessioni SSH con la tastiera
|
||||
- **SSH Ricco di Funzionalità** - Supporta jump host, Warpgate, connessioni basate su TOTP, SOCKS5, verifica chiave host, compilazione automatica password, [OPKSSH](https://github.com/openpubkey/opkssh), ecc.
|
||||
- **Grafico di Rete** - Personalizza la tua Dashboard per visualizzare il tuo homelab basato sulle connessioni SSH con supporto dello stato
|
||||
- **Schede Persistenti** - Le sessioni SSH e le schede rimangono aperte tra dispositivi/aggiornamenti se abilitato nel profilo utente
|
||||
- **Cronologia Comandi** - Autocompletamento e visualizzazione dei comandi SSH eseguiti in precedenza.
|
||||
- **Connessione Rapida** - Connettiti a un server senza dover salvare i dati di connessione.
|
||||
- **Palette Comandi** - Premi due volte shift sinistro per accedere rapidamente alle connessioni SSH con la tastiera.
|
||||
- **SSH Ricco di Funzionalità** - Supporta jump host, Warpgate, connessioni basate su TOTP, SOCKS5, verifica chiave host, compilazione automatica password, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, ecc.
|
||||
- **Grafico di Rete** - Personalizza la tua Dashboard per visualizzare il tuo homelab basato sulle connessioni SSH con supporto dello stato.
|
||||
- **Schede Persistenti** - Le sessioni SSH e le schede rimangono aperte tra dispositivi/aggiornamenti se abilitato nel profilo utente.
|
||||
|
||||
# Funzionalità Pianificate
|
||||
|
||||
@@ -102,7 +87,7 @@ Dispositivi Supportati:
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
Visita la [Documentazione](https://docs.termix.site/install) di Termix per maggiori informazioni su come installare Termix su tutte le piattaforme. In alternativa, visualizza un file Docker Compose di esempio qui:
|
||||
Visita la [Documentazione](https://docs.termix.site/install) di Termix per maggiori informazioni su come installare Termix su tutte le piattaforme. In alternativa, visualizza un file Docker Compose di esempio qui (puoi omettere guacd e la rete se non prevedi di utilizzare le funzioni di desktop remoto):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -122,7 +107,7 @@ services:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -151,17 +136,29 @@ networks:
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Crowdin">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Crowdin">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# Supporto
|
||||
|
||||
Se hai bIPAgno di aiuto o vuoi richiedere una funzionalità per Termix, visita la pagina [Segnalazioni](https://github.com/Termix-SSH/Support/issues), accedi e premi `New Issue`.
|
||||
Se hai bisogno di aiuto o vuoi richiedere una funzionalità per Termix, visita la pagina [Segnalazioni](https://github.com/Termix-SSH/Support/issues), accedi e premi `New Issue`.
|
||||
Per favore, sii il più dettagliato possibile nella tua segnalazione, preferibilmente scritta in inglese. Puoi anche unirti al server [Discord](https://discord.gg/jVQGdvHDrf) e visitare il canale di supporto, tuttavia i tempi di risposta potrebbero essere più lunghi.
|
||||
|
||||
# Screenshot
|
||||
|
||||
+43
-46
@@ -1,20 +1,7 @@
|
||||
# リポジトリ統計
|
||||
|
||||
<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-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>
|
||||
<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>
|
||||
</p>
|
||||
|
||||

|
||||
@@ -34,9 +21,6 @@
|
||||
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||
</p>
|
||||
|
||||
プロジェクトを支援していただける方はこちらからどうぞ!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# 概要
|
||||
|
||||
<p align="center">
|
||||
@@ -44,33 +28,34 @@
|
||||
<img alt="Termix Banner" src=../public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||
</p>
|
||||
|
||||
Termixは、オープンソースで永久無料のセルフホスト型オールインワンサーバー管理プラットフォームです。単一の直感的なインターフェースを通じて、サーバーとインフラストラクチャを管理するマルチプラットフォームソリューションを提供します。Termixは、SSHターミナルアクセス、SSHトンネリング機能、リモートファイル管理、その他多くのツールを提供します。Termixは、すべてのプラットフォームで利用可能なTermiusの完全無料でセルフホスト可能な代替ソリューションです。
|
||||
Termixは、オープンソースで永久無料のセルフホスト型オールインワンサーバー管理プラットフォームです。単一の直感的なインターフェースを通じて、サーバーとインフラストラクチャを管理するマルチプラットフォームソリューションを提供します。Termixは、SSHターミナルアクセス、リモートデスクトップ制御(RDP、VNC、Telnet)、SSHトンネリング機能、リモートSSHファイル管理、およびその他多くのツールを提供します。Termixは、すべてのプラットフォームで利用可能なTermiusの完全無料でセルフホスト可能な代替ソリューションです。
|
||||
|
||||
# 機能
|
||||
|
||||
- **SSHターミナルアクセス** - ブラウザ風タブシステムによる分割画面対応(最大4パネル)のフル機能ターミナル。一般的なターミナルテーマ、フォント、その他のコンポーネントを含むターミナルカスタマイズに対応
|
||||
- **リモートデスクトップアクセス** - ブラウザ上でRDP、VNC、Telnetをサポート、完全なカスタマイズと分割画面に対応
|
||||
- **SSHトンネル管理** - 自動再接続とヘルスモニタリング機能を備えたSSHトンネルの作成・管理、-l または -r 接続に対応
|
||||
- **リモートファイルマネージャー** - コード、画像、音声、動画の表示・編集に対応し、リモートサーバー上のファイルを直接管理。sudo対応でファイルのアップロード、ダウンロード、名前変更、削除、移動をシームレスに実行
|
||||
- **Docker管理** - コンテナの起動、停止、一時停止、削除。コンテナの統計情報を表示。docker execターミナルでコンテナを操作。PortainerやDockgeの代替ではなく、コンテナの作成よりも簡易管理を目的としています
|
||||
- **SSHホストマネージャー** - タグやフォルダでSSH接続を保存、整理、管理し、再利用可能なログイン情報を簡単に保存しながらSSHキーのデプロイを自動化
|
||||
- **サーバー統計** - ほとんどのLinuxベースのサーバーで、CPU、メモリ、ディスク使用量、ネットワーク、アップタイム、システム情報、ファイアウォール、ポートモニターを表示
|
||||
- **ダッシュボード** - ダッシュボードでサーバー情報を一目で確認
|
||||
- **RBAC** - ロールを作成し、ユーザー/ロール間でホストを共有
|
||||
- **ユーザー認証** - 管理者コントロールとOIDCおよび2FA(TOTP)対応による安全なユーザー管理。すべてのプラットフォームでアクティブなユーザーセッションを表示し、権限を取り消し可能。OIDC/ローカルアカウントの連携
|
||||
- **データベース暗号化** - バックエンドは暗号化されたSQLiteデータベースファイルとして保存。詳細は[ドキュメント](https://docs.termix.site/security)をご覧ください
|
||||
- **データのエクスポート/インポート** - SSHホスト、認証情報、ファイルマネージャーデータのエクスポートとインポート
|
||||
- **自動SSL設定** - HTTPSリダイレクト付きの組み込みSSL証明書生成・管理
|
||||
- **モダンUI** - React、Tailwind CSS、Shadcnで構築された、デスクトップ/モバイル対応のクリーンなインターフェース。ダーク/ライトモードの切り替え対応。URLルートで任意の接続をフルスクリーンで開くことが可能
|
||||
- **多言語対応** - 約30言語の組み込みサポート([Crowdin](https://docs.termix.site/translations)で管理)
|
||||
- **プラットフォーム対応** - Webアプリ、デスクトップアプリケーション(Windows、Linux、macOS)、PWA、iOS・Android専用モバイル/タブレットアプリとして利用可能
|
||||
- **SSHツール** - ワンクリックで実行できる再利用可能なコマンドスニペットの作成。複数の開いているターミナルに対して同時にコマンドを実行
|
||||
- **コマンド履歴** - 過去に実行したSSHコマンドの自動補完と表示
|
||||
- **クイック接続** - 接続データを保存せずにサーバーに接続
|
||||
- **コマンドパレット** - 左Shiftキーを2回押すことで、キーボードからSSH接続に素早くアクセス
|
||||
- **SSH機能充実** - ジャンプホスト、Warpgate、TOTPベースの接続、SOCKS5、ホストキー検証、パスワード自動入力、[OPKSSH](https://github.com/openpubkey/opkssh)などに対応
|
||||
- **ネットワークグラフ** - ダッシュボードをカスタマイズして、SSH接続に基づくホームラボのネットワークをステータス表示付きで可視化
|
||||
- **永続タブ** - ユーザープロフィールで有効にすると、SSHセッションとタブがデバイス/更新をまたいで開いたまま保持されます
|
||||
- **SSHターミナルアクセス** - ブラウザ風タブシステムによる分割画面対応(最大4パネル)のフル機能ターミナル。一般的なターミナルテーマ、フォント、その他のコンポーネントを含むターミナルカスタマイズに対応しています。
|
||||
- **リモートデスクトップアクセス** - ブラウザ上でRDP、VNC、Telnetをサポート、完全なカスタマイズと分割画面に対応しています。
|
||||
- **SSHトンネル管理** - 自動再接続とヘルスモニタリング、ローカル・リモート・ダイナミックSOCKSフォワーディングを備えたサーバー間SSHトンネルの作成・管理が可能です。デスクトップクライアント対サーバーのトンネル設定はデスクトップインストールごとにローカルに保存され、オプションのC2Sプリセットスナップショットをサーバーに保存・名前変更・読み込み・削除してクライアント間でローカルトンネル設定を移動できます。
|
||||
- **リモートファイルマネージャー** - コード、画像、音声、動画の表示・編集に対応し、リモートサーバー上のファイルを直接管理できます。sudo対応でファイルのアップロード、ダウンロード、名前変更、削除、移動をシームレスに実行できます。
|
||||
- **Docker管理** - コンテナの起動、停止、一時停止、削除。コンテナの統計情報を表示。docker execターミナルでコンテナを操作。PortainerやDockgeの代替ではなく、コンテナの作成よりも簡易的な管理を目的としています。
|
||||
- **SSHホストマネージャー** - タグやフォルダでSSH接続を保存、整理、管理し、再利用可能なログイン情報を簡単に保存しながらSSHキーのデプロイを自動化できます。
|
||||
- **サーバー統計** - ほとんどのLinuxベースのサーバーで、CPU、メモリ、ディスク使用量、ネットワーク、アップタイム、システム情報、ファイアウォール、ポートモニターを表示できます。
|
||||
- **ダッシュボード** - ダッシュボードでサーバー情報を一目で確認できます。
|
||||
- **RBAC** - ロールを作成し、ユーザー/ロール間でホストを共有できます。
|
||||
- **ユーザー認証** - 管理者コントロールとOIDC(アクセス制御付き)および2FA(TOTP)対応による安全なユーザー管理。すべてのプラットフォームでアクティブなユーザーセッションを表示し、権限を取り消し可能。OIDC/ローカルアカウントの連携が可能です。
|
||||
- **データベース暗号化** - バックエンドは暗号化されたSQLiteデータベースファイルとして保存されます。詳細は[ドキュメント](https://docs.termix.site/security)をご覧ください。
|
||||
- **APIキー** - 自動化/CI用に有効期限付きのユーザースコープAPIキーを作成できます。
|
||||
- **データのエクスポート/インポート** - SSHホスト、認証情報、ファイルマネージャーデータのエクスポートとインポートが可能です。
|
||||
- **自動SSL設定** - HTTPSリダイレクト付きの組み込みSSL証明書生成・管理が可能です。
|
||||
- **モダンUI** - React、Tailwind CSS、Shadcnで構築された、デスクトップ/モバイル対応のクリーンなインターフェース。ライト、ダーク、Draculaなど、多くの異なるUIテーマから選択可能。URLルートで任意の接続をフルスクリーンで開くことができます。
|
||||
- **多言語対応** - 約30言語の組み込みサポート([Crowdin](https://docs.termix.site/translations)で管理されています)。
|
||||
- **プラットフォーム対応** - Webアプリ、デスクトップアプリケーション(Windows、Linux、macOS。Termixバックエンドなしでスタンドアロン動作可能)、PWA、iOS・Android専用モバイル/タブレットアプリとして利用可能です。
|
||||
- **SSHツール** - ワンクリックで実行できる再利用可能なコマンドスニペットの作成。複数の開いているターミナルに対して同時にコマンドを実行できます。
|
||||
- **コマンド履歴** - 過去に実行したSSHコマンドの自動補完と表示が可能です。
|
||||
- **クイック接続** - 接続データを保存せずにサーバーに接続できます。
|
||||
- **コマンドパレット** - 左Shiftキーを2回押すことで、キーボードからSSH接続に素早くアクセスできます。
|
||||
- **SSH機能充実** - ジャンプホスト、Warpgate、TOTPベースの接続、SOCKS5、ホストキー検証、パスワード自動入力、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、ポート敲き(port knocking)などに対応しています。
|
||||
- **ネットワークグラフ** - ダッシュボードをカスタマイズして、SSH接続に基づくホームラボのネットワークをステータス表示付きで可視化できます。
|
||||
- **永続タブ** - ユーザープロフィールで有効にすると、SSHセッションとタブがデバイス/更新をまたいで開いたまま保持されます。
|
||||
|
||||
# 予定されている機能
|
||||
|
||||
@@ -102,7 +87,7 @@ Termixは、オープンソースで永久無料のセルフホスト型オー
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
すべてのプラットフォームへのTermixのインストール方法については、Termixの[ドキュメント](https://docs.termix.site/install)をご覧ください。以下はDocker Composeファイルのサンプルです:
|
||||
すべてのプラットフォームへのTermixのインストール方法については、Termixの[ドキュメント](https://docs.termix.site/install)をご覧ください。または、以下のサンプルDocker Composeファイルをご覧ください(リモートデスクトップ機能を使用する予定がない場合は、guacdとネットワークの設定を省略できます):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -122,7 +107,7 @@ services:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -151,11 +136,23 @@ networks:
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Crowdin">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Crowdin">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -197,7 +194,7 @@ Termixに関するヘルプや機能リクエストが必要な場合は、[Issu
|
||||
<img src="../repo-images/Image%2012.png" width="400" alt="Termix Demo 12"/>
|
||||
</p>
|
||||
|
||||
一部の動画や画像は古い場合や、機能を完全に紹介していない場合があります。
|
||||
動画や画像の一部は最新ではない場合や、機能を完全に紹介できていない場合があります。
|
||||
|
||||
# ライセンス
|
||||
|
||||
|
||||
+42
-45
@@ -1,20 +1,7 @@
|
||||
# 리포지토리 통계
|
||||
|
||||
<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-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>
|
||||
<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>
|
||||
</p>
|
||||
|
||||

|
||||
@@ -34,9 +21,6 @@
|
||||
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||
</p>
|
||||
|
||||
프로젝트를 후원하고 싶으시다면 여기에서 지원해 주세요!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# 개요
|
||||
|
||||
<p align="center">
|
||||
@@ -44,33 +28,34 @@
|
||||
<img alt="Termix Banner" src=../public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||
</p>
|
||||
|
||||
Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버 관리 플랫폼입니다. 단일 직관적인 인터페이스를 통해 서버와 인프라를 관리할 수 있는 멀티 플랫폼 솔루션을 제공합니다. Termix는 SSH 터미널 접속, SSH 터널링 기능, 원격 파일 관리 및 기타 다양한 도구를 제공합니다. Termix는 모든 플랫폼에서 사용 가능한 Termius의 완벽한 무료 셀프 호스팅 대안입니다.
|
||||
Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버 관리 플랫폼입니다. 단일 직관적인 인터페이스를 통해 서버와 인프라를 관리할 수 있는 멀티 플랫폼 솔루션을 제공합니다. Termix는 SSH 터미널 접속, 원격 데스크톱 제어(RDP, VNC, Telnet), SSH 터널링 기능, 원격 SSH 파일 관리 및 기타 다양한 도구를 제공합니다. Termix는 모든 플랫폼에서 사용 가능한 Termius의 완벽한 무료 셀프 호스팅 대안입니다.
|
||||
|
||||
# 기능
|
||||
|
||||
- **SSH 터미널 접속** - 브라우저 스타일 탭 시스템과 분할 화면 지원(최대 4개 패널)을 갖춘 완전한 기능의 터미널. 일반 터미널 테마, 글꼴 및 기타 구성 요소를 포함한 터미널 사용자 정의 지원
|
||||
- **원격 데스크톱 접속** - 완전한 사용자 정의와 분할 화면을 지원하는 브라우저 기반 RDP, VNC, Telnet 지원
|
||||
- **SSH 터널 관리** - 자동 재연결 및 상태 모니터링 기능을 갖춘 SSH 터널 생성 및 관리, -l 또는 -r 연결 지원
|
||||
- **원격 파일 관리자** - 코드, 이미지, 오디오, 비디오의 보기 및 편집을 지원하여 원격 서버에서 파일을 직접 관리. sudo 지원으로 파일 업로드, 다운로드, 이름 변경, 삭제, 이동을 원활하게 수행
|
||||
- **Docker 관리** - 컨테이너 시작, 중지, 일시 정지, 제거. 컨테이너 통계 보기. docker exec 터미널로 컨테이너 제어. Portainer나 Dockge를 대체하기 위한 것이 아니라 컨테이너 생성보다는 간편한 관리를 목적으로 합니다
|
||||
- **SSH 호스트 관리자** - 태그와 폴더로 SSH 연결을 저장, 정리, 관리하고, 재사용 가능한 로그인 정보를 쉽게 저장하면서 SSH 키 배포를 자동화
|
||||
- **서버 통계** - 대부분의 Linux 기반 서버에서 CPU, 메모리, 디스크 사용량과 함께 네트워크, 업타임, 시스템 정보, 방화벽, 포트 모니터를 표시
|
||||
- **대시보드** - 대시보드에서 서버 정보를 한눈에 확인
|
||||
- **RBAC** - 역할을 생성하고 사용자/역할 간에 호스트 공유
|
||||
- **사용자 인증** - 관리자 제어와 OIDC 및 2FA(TOTP) 지원을 통한 안전한 사용자 관리. 모든 플랫폼에서 활성 사용자 세션을 보고 권한을 취소 가능. OIDC/로컬 계정 연동
|
||||
- **데이터베이스 암호화** - 백엔드가 암호화된 SQLite 데이터베이스 파일로 저장됨. 자세한 내용은 [문서](https://docs.termix.site/security)를 참조하세요
|
||||
- **데이터 내보내기/가져오기** - SSH 호스트, 자격 증명, 파일 관리자 데이터의 내보내기 및 가져오기
|
||||
- **자동 SSL 설정** - HTTPS 리디렉션을 포함한 내장 SSL 인증서 생성 및 관리
|
||||
- **모던 UI** - React, Tailwind CSS, Shadcn으로 구축된 깔끔한 데스크톱/모바일 친화적 인터페이스. 다크 또는 라이트 모드 기반 UI 선택. URL 라우트를 사용하여 모든 연결을 전체 화면으로 열기 가능
|
||||
- **다국어 지원** - 약 30개 언어 내장 지원([Crowdin](https://docs.termix.site/translations)으로 관리)
|
||||
- **플랫폼 지원** - 웹 앱, 데스크톱 애플리케이션(Windows, Linux, macOS), PWA, iOS 및 Android 전용 모바일/태블릿 앱으로 제공
|
||||
- **SSH 도구** - 한 번의 클릭으로 실행 가능한 재사용 가능 명령어 스니펫 생성. 여러 열린 터미널에서 동시에 하나의 명령어 실행
|
||||
- **명령어 기록** - 이전에 실행한 SSH 명령어의 자동 완성 및 조회
|
||||
- **빠른 연결** - 연결 데이터를 저장하지 않고 서버에 접속
|
||||
- **명령어 팔레트** - 왼쪽 Shift 키를 두 번 눌러 키보드로 SSH 연결에 빠르게 접근
|
||||
- **풍부한 SSH 기능** - 점프 호스트, Warpgate, TOTP 기반 연결, SOCKS5, 호스트 키 검증, 비밀번호 자동 입력, [OPKSSH](https://github.com/openpubkey/opkssh) 등 지원
|
||||
- **네트워크 그래프** - 대시보드를 사용자 정의하여 SSH 연결 기반의 홈랩 네트워크를 상태 표시와 함께 시각화
|
||||
- **지속 탭** - 사용자 프로필에서 활성화된 경우 SSH 세션 및 탭이 기기/새로 고침 간에 열린 상태 유지
|
||||
- **SSH 터미널 접속** - 브라우저 스타일 탭 시스템과 분할 화면 지원(최대 4개 패널)을 갖춘 완전한 기능의 터미널. 일반 터미널 테마, 글꼴 및 기타 구성 요소를 포함한 터미널 사용자 정의 지원.
|
||||
- **원격 데스크톱 접속** - 완전한 사용자 정의와 분할 화면을 지원하는 브라우저 기반 RDP, VNC, Telnet 지원.
|
||||
- **SSH 터널 관리** - 자동 재연결, 상태 모니터링, 로컬·원격·동적 SOCKS 포워딩을 지원하는 서버 간 SSH 터널 생성 및 관리. 데스크톱 클라이언트-서버 터널 설정은 데스크톱 설치별로 로컬에 저장되며, 선택적 C2S 사전 설정 스냅샷을 서버에 저장·이름 변경·불러오기·삭제하여 클라이언트 간 로컬 터널 구성을 이동할 수 있습니다.
|
||||
- **원격 파일 관리자** - 코드, 이미지, 오디오, 비디오의 보기 및 편집을 지원하여 원격 서버에서 파일을 직접 관리. sudo 지원으로 파일 업로드, 다운로드, 이름 변경, 삭제, 이동을 원활하게 수행.
|
||||
- **Docker 관리** - 컨테이너 시작, 중지, 일시 정지, 제거. 컨테이너 통계 보기. docker exec 터미널로 컨테이너 제어. Portainer나 Dockge를 대체하기 위한 것이 아니라 컨테이너 생성보다는 간편한 관리를 목적으로 합니다.
|
||||
- **SSH 호스트 관리자** - 태그와 폴더로 SSH 연결을 저장, 정리, 관리하고, 재사용 가능한 로그인 정보를 쉽게 저장하면서 SSH 키 배포를 자동화.
|
||||
- **서버 통계** - 대부분의 Linux 기반 서버에서 CPU, 메모리, 디스크 사용량과 함께 네트워크, 업타임, 시스템 정보, 방화벽, 포트 모니터를 표시.
|
||||
- **대시보드** - 대시보드에서 서버 정보를 한눈에 확인.
|
||||
- **RBAC** - 역할을 생성하고 사용자/역할 간에 호스트 공유.
|
||||
- **사용자 인증** - 관리자 제어와 OIDC(액세스 제어 포함) 및 2FA(TOTP) 지원을 통한 안전한 사용자 관리. 모든 플랫폼에서 활성 사용자 세션을 보고 권한을 취소 가능. OIDC/로컬 계정 연동.
|
||||
- **데이터베이스 암호화** - 백엔드가 암호화된 SQLite 데이터베이스 파일로 저장됨. 자세한 내용은 [문서](https://docs.termix.site/security)를 참조하세요.
|
||||
- **API 키** - 자동화/CI에 사용할 만료일이 있는 사용자 범위 API 키 생성.
|
||||
- **데이터 내보내기/가져오기** - SSH 호스트, 자격 증명, 파일 관리자 데이터의 내보내기 및 가져오기.
|
||||
- **자동 SSL 설정** - HTTPS 리디렉션을 포함한 내장 SSL 인증서 생성 및 관리.
|
||||
- **모던 UI** - React, Tailwind CSS, Shadcn으로 구축된 깔끔한 데스크톱/모바일 친화적 인터페이스. 라이트, 다크, 드라큘라 등 다양한 UI 테마 선택 가능. URL 라우트를 사용하여 모든 연결을 전체 화면으로 열기 가능.
|
||||
- **다국어 지원** - 약 30개 언어 내장 지원([Crowdin](https://docs.termix.site/translations)으로 관리).
|
||||
- **플랫폼 지원** - 웹 앱, 데스크톱 애플리케이션(Windows, Linux, macOS, Termix 백엔드 없이 독립 실행 가능), PWA, iOS 및 Android 전용 모바일/태블릿 앱으로 제공.
|
||||
- **SSH 도구** - 한 번의 클릭으로 실행 가능한 재사용 가능 명령어 스니펫 생성. 여러 열린 터미널에서 동시에 하나의 명령어 실행.
|
||||
- **명령어 기록** - 이전에 실행한 SSH 명령어의 자동 완성 및 조회.
|
||||
- **빠른 연결** - 연결 데이터를 저장하지 않고 서버에 접속.
|
||||
- **명령어 팔레트** - 왼쪽 Shift 키를 두 번 눌러 키보드로 SSH 연결에 빠르게 접근.
|
||||
- **풍부한 SSH 기능** - 점프 호스트, Warpgate, TOTP 기반 연결, SOCKS5, 호스트 키 검증, 비밀번호 자동 입력, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, 포트 노킹 등 지원.
|
||||
- **네트워크 그래프** - 대시보드를 사용자 정의하여 SSH 연결 기반의 홈랩 네트워크를 상태 표시와 함께 시각화.
|
||||
- **지속 탭** - 사용자 프로필에서 활성화된 경우 SSH 세션 및 탭이 기기/새로 고침 간에 열린 상태 유지.
|
||||
|
||||
# 계획된 기능
|
||||
|
||||
@@ -102,7 +87,7 @@ Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
모든 플랫폼에 Termix를 설치하는 방법에 대한 자세한 내용은 Termix [문서](https://docs.termix.site/install)를 방문하세요. 다음은 Docker Compose 파일 예시입니다:
|
||||
모든 플랫폼에 Termix를 설치하는 방법에 대한 자세한 내용은 Termix [문서](https://docs.termix.site/install)를 방문하세요. 다음은 Docker Compose 파일 예시입니다(원격 데스크톱 기능을 사용할 계획이 없다면 guacd와 네트워크를 생략할 수 있습니다):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -122,7 +107,7 @@ services:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -151,11 +136,23 @@ networks:
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Crowdin">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Crowdin">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
+33
-36
@@ -1,20 +1,7 @@
|
||||
# Estatísticas do Repositório
|
||||
|
||||
<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-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>
|
||||
<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>
|
||||
</p>
|
||||
|
||||

|
||||
@@ -34,9 +21,6 @@
|
||||
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||
</p>
|
||||
|
||||
Se desejar, você pode apoiar o projeto aqui!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# Visão Geral
|
||||
|
||||
<p align="center">
|
||||
@@ -44,31 +28,32 @@ Se desejar, você pode apoiar o projeto aqui!\
|
||||
<img alt="Termix Banner" src=../public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||
</p>
|
||||
|
||||
Termix é uma plataforma de gerenciamento de servidores tudo-em-um, de código aberto, sempre gratuita e auto-hospedada. Ela fornece uma solução multiplataforma para gerenciar seus servidores e infraestrutura através de uma interface única e intuitiva. Termix oferece acesso a terminal SSH, capacidades de tunelamento SSH, gerenciamento remoto de arquivos e muitas outras ferramentas. Termix é a alternativa perfeita, gratuita e auto-hospedada ao Termius, disponível para todas as plataformas.
|
||||
Termix é uma plataforma de gerenciamento de servidores tudo-em-um, de código aberto, sempre gratuita e auto-hospedada. Ela fornece uma solução multiplataforma para gerenciar seus servidores e infraestrutura através de uma interface única e intuitiva. Termix oferece acesso a terminal SSH, controle de desktop remoto (RDP, VNC, Telnet), capacidades de tunelamento SSH, gerenciamento remoto de arquivos SSH e muitas outras ferramentas. Termix é a alternativa perfeita, gratuita e auto-hospedada ao Termius, disponível para todas as plataformas.
|
||||
|
||||
# Funcionalidades
|
||||
|
||||
- **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
|
||||
- **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.
|
||||
- **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
|
||||
- **Estatísticas do Servidor** - Visualize o uso de CPU, memória e disco junto com rede, tempo de atividade, informações do sistema, firewall, monitor de portas na maioria dos servidores baseados em Linux
|
||||
- **Dashboard** - Visualize informações do servidor de relance no seu dashboard
|
||||
- **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 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.
|
||||
- **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
|
||||
- **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 modo escuro ou claro. 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.
|
||||
- **Idiomas** - Suporte integrado para ~30 idiomas (gerenciado pelo [Crowdin](https://docs.termix.site/translations))
|
||||
- **Suporte a Plataformas** - Disponível como aplicação web, aplicação desktop (Windows, Linux e macOS), PWA e aplicativo dedicado para celular/tablet para iOS e Android.
|
||||
- **Suporte a Plataformas** - Disponível como aplicação web, aplicação desktop (Windows, Linux e macOS, pode ser executado de forma independente sem o backend Termix), PWA e aplicativo dedicado para celular/tablet para iOS e Android.
|
||||
- **Ferramentas SSH** - Crie trechos de comandos reutilizáveis que são executados com um único clique. Execute um comando simultaneamente em múltiplos terminais abertos.
|
||||
- **Histórico de Comandos** - Autocompletar e visualizar comandos SSH executados anteriormente
|
||||
- **Conexão Rápida** - Conecte-se a um servidor sem precisar salvar os dados de conexão
|
||||
- **Paleta de Comandos** - Pressione duas vezes a tecla Shift esquerda para acessar rapidamente as conexões SSH com seu teclado
|
||||
- **SSH Rico em Funcionalidades** - Suporta jump hosts, Warpgate, conexões baseadas em TOTP, SOCKS5, verificação de chave do host, preenchimento automático de senhas, [OPKSSH](https://github.com/openpubkey/opkssh), etc.
|
||||
- **SSH Rico em Funcionalidades** - Suporta jump hosts, Warpgate, conexões baseadas em TOTP, SOCKS5, verificação de chave do host, preenchimento automático de senhas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc.
|
||||
- **Gráfico de Rede** - Personalize seu Dashboard para visualizar seu homelab baseado nas suas conexões SSH com suporte de status
|
||||
- **Abas Persistentes** - Sessões SSH e abas permanecem abertas entre dispositivos/atualizações se habilitado no perfil do usuário
|
||||
|
||||
@@ -102,7 +87,7 @@ Dispositivos suportados:
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
Visite a [documentação](https://docs.termix.site/install) do Termix para mais informações sobre como instalar o Termix em todas as plataformas. Caso contrário, veja um arquivo Docker Compose de exemplo aqui:
|
||||
Visite a [documentação](https://docs.termix.site/install) do Termix para mais informações sobre como instalar o Termix em todas as plataformas. Caso contrário, veja um arquivo Docker Compose de exemplo aqui (você pode omitir o guacd e a rede se não planeja usar recursos de área de trabalho remota):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -122,7 +107,7 @@ services:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -151,11 +136,23 @@ networks:
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Crowdin">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Crowdin">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -170,26 +167,26 @@ Por favor, seja o mais detalhado possível no seu relato, preferencialmente escr
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||
<img src="../repo-images/Image%202.png" width="400" alt="Termix Demo 2"/>
|
||||
<img src="../repo-images/Image 2.png" width="400" alt="Termix Demo 2"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%203.png" width="400" alt="Termix Demo 3"/>
|
||||
<img src="../repo-images/Image%204.png" width="400" alt="Termix Demo 4"/>
|
||||
<img src="../repo-images/Image 3.png" width="400" alt="Termix Demo 3"/>
|
||||
<img src="../repo-images/Image 4.png" width="400" alt="Termix Demo 4"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%205.png" width="400" alt="Termix Demo 5"/>
|
||||
<img src="../repo-images/Image%206.png" width="400" alt="Termix Demo 6"/>
|
||||
<img src="../repo-images/Image 5.png" width="400" alt="Termix Demo 5"/>
|
||||
<img src="../repo-images/Image 6.png" width="400" alt="Termix Demo 6"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%207.png" width="400" alt="Termix Demo 7"/>
|
||||
<img src="../repo-images/Image%208.png" width="400" alt="Termix Demo 8"/>
|
||||
<img src="../repo-images/Image 7.png" width="400" alt="Termix Demo 7"/>
|
||||
<img src="../repo-images/Image 8.png" width="400" alt="Termix Demo 8"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%209.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%2010.png" width="400" alt="Termix Demo 10"/>
|
||||
</p>
|
||||
|
||||
|
||||
+36
-39
@@ -1,20 +1,7 @@
|
||||
# Статистика репозитория
|
||||
|
||||
<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-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>
|
||||
<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>
|
||||
</p>
|
||||
|
||||

|
||||
@@ -34,9 +21,6 @@
|
||||
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||
</p>
|
||||
|
||||
Если хотите, вы можете поддержать проект здесь!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# Обзор
|
||||
|
||||
<p align="center">
|
||||
@@ -44,31 +28,32 @@
|
||||
<img alt="Termix Banner" src=../public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||
</p>
|
||||
|
||||
Termix — это платформа для управления серверами с открытым исходным кодом, навсегда бесплатная и размещаемая на собственном сервере. Она предоставляет мультиплатформенное решение для управления вашими серверами и инфраструктурой через единый интуитивно понятный интерфейс. Termix предлагает доступ к SSH-терминалу, возможности SSH-туннелирования, удалённое управление файлами и множество других инструментов. Termix — это идеальная бесплатная альтернатива Termius с возможностью размещения на собственном сервере, доступная для всех платформ.
|
||||
Termix — это платформа для управления серверами с открытым исходным кодом, навсегда бесплатная и размещаемая на собственном сервере. Она предоставляет мультиплатформенное решение для управления вашими серверами и инфраструктурой через единый интуитивно понятный интерфейс. Termix предлагает доступ к SSH-терминалу, управление удаленным рабочим столом (RDP, VNC, Telnet), возможности SSH-туннелирования, удаленное управление файлами SSH и множество других инструментов. Termix — это идеальная бесплатная альтернатива Termius с возможностью размещения на собственном сервере, доступная для всех платформ.
|
||||
|
||||
# Возможности
|
||||
|
||||
- **Доступ к SSH-терминалу** — Полнофункциональный терминал с поддержкой разделения экрана (до 4 панелей) и системой вкладок, как в браузере. Включает поддержку настройки терминала, включая популярные темы, шрифты и другие компоненты
|
||||
- **Доступ к SSH-терминалу** — Полнофункциональный терминал с поддержкой разделения экрана (до 4 панелей) и системой вкладок, как в браузере. Включает поддержку настройки терминала, включая популярные темы, шрифты и другие компоненты.
|
||||
- **Доступ к удалённому рабочему столу** — Поддержка RDP, VNC и Telnet через браузер с полной настройкой и разделением экрана
|
||||
- **Управление SSH-туннелями** — Создание и управление SSH-туннелями с автоматическим переподключением и мониторингом состояния, с поддержкой соединений -l и -r
|
||||
- **Удалённый файловый менеджер** — Управление файлами непосредственно на удалённых серверах с поддержкой просмотра и редактирования кода, изображений, аудио и видео. Загрузка, скачивание, переименование, удаление и перемещение файлов с поддержкой sudo
|
||||
- **Управление Docker** — Запуск, остановка, приостановка, удаление контейнеров. Просмотр статистики контейнеров. Управление контейнером через терминал docker exec. Не предназначен для замены Portainer или Dockge, а скорее для простого управления контейнерами по сравнению с их созданием
|
||||
- **Управление SSH-туннелями** — Создание и управление межсерверными SSH-туннелями с автоматическим переподключением, мониторингом состояния и локальной, удалённой или динамической SOCKS-переадресацией. Настройки туннелей «десктопный клиент — сервер» хранятся локально для каждой установки; опциональные снимки C2S-пресетов можно сохранять на сервере, переименовывать, загружать или удалять для переноса конфигурации между клиентами.
|
||||
- **Удалённый файловый менеджер** — Управление файлами непосредственно на удалённых серверах с поддержкой просмотра и редактирования кода, изображений, аудио и видео. Загрузка, скачивание, переименование, удаление и перемещение файлов с поддержкой sudo.
|
||||
- **Управление Docker** — Запуск, остановка, приостановка, удаление контейнеров. Просмотр статистики контейнеров. Управление контейнером через терминал docker exec. Не предназначен для замены Portainer или Dockge, а скорее для простого управления контейнерами по сравнению с их созданием.
|
||||
- **Менеджер SSH-хостов** — Сохранение, организация и управление SSH-подключениями с помощью тегов и папок, с возможностью сохранения данных для повторного входа и автоматизации развёртывания SSH-ключей
|
||||
- **Статистика сервера** — Просмотр использования CPU, памяти и диска, а также сети, времени работы, информации о системе, файрвола и монитора портов на большинстве серверов на базе Linux
|
||||
- **Панель управления** — Просмотр информации о сервере на панели управления одним взглядом
|
||||
- **RBAC** — Создание ролей и предоставление общего доступа к хостам для пользователей/ролей
|
||||
- **Аутентификация пользователей** — Безопасное управление пользователями с административным контролем и поддержкой OIDC и 2FA (TOTP). Просмотр активных сессий пользователей на всех платформах и отзыв прав доступа. Связывание аккаунтов OIDC/локальных аккаунтов
|
||||
- **Аутентификация пользователей** — Безопасное управление пользователями с административным контролем и поддержкой OIDC (с контролем доступа) и 2FA (TOTP). Просмотр активных сессий пользователей на всех платформах и отзыв прав доступа. Связывание аккаунтов OIDC/локальных аккаунтов.
|
||||
- **Шифрование базы данных** — Бэкенд хранится в виде зашифрованных файлов базы данных SQLite. Подробнее в [документации](https://docs.termix.site/security)
|
||||
- **API-ключи** — Создание API-ключей с областью видимости пользователя и сроками действия для использования в автоматизации/CI.
|
||||
- **Экспорт/импорт данных** — Экспорт и импорт SSH-хостов, учётных данных и данных файлового менеджера
|
||||
- **Автоматическая настройка SSL** — Встроенная генерация и управление SSL-сертификатами с перенаправлением на HTTPS
|
||||
- **Современный интерфейс** — Чистый интерфейс для десктопа и мобильных устройств, построенный на React, Tailwind CSS и Shadcn. Выбор между тёмной и светлой темой. Использование URL-маршрутов для открытия любого подключения в полноэкранном режиме
|
||||
- **Современный интерфейс** — Чистый интерфейс для десктопа и мобильных устройств, построенный на React, Tailwind CSS и Shadcn. Выбор между множеством различных тем интерфейса, включая светлую, тёмную, Dracula и т. д. Использование URL-маршрутов для открытия любого подключения в полноэкранном режиме.
|
||||
- **Языки** — Встроенная поддержка ~30 языков (управляется через [Crowdin](https://docs.termix.site/translations))
|
||||
- **Поддержка платформ** — Доступен как веб-приложение, настольное приложение (Windows, Linux и macOS), PWA и специализированное мобильное/планшетное приложение для iOS и Android
|
||||
- **Инструменты SSH** — Создание переиспользуемых фрагментов команд, выполняемых одним нажатием. Запуск одной команды одновременно в нескольких открытых терминалах
|
||||
- **Поддержка платформ** — Доступен как веб-приложение, настольное приложение (Windows, Linux и macOS, может работать автономно без бэкенда Termix), PWA и специализированное мобильное/планшетное приложение для iOS и Android.
|
||||
- **Инструменты SSH** — Создание переиспользуемых фрагментов команд, выполняемых одним нажатием. Запуск одной команды одновременно в нескольких открытых терминалах.
|
||||
- **История команд** — Автодополнение и просмотр ранее выполненных SSH-команд
|
||||
- **Быстрое подключение** — Подключение к серверу без необходимости сохранения данных подключения
|
||||
- **Командная палитра** — Двойное нажатие левого Shift для быстрого доступа к SSH-подключениям с клавиатуры
|
||||
- **Богатый функционал SSH** — Поддержка jump-хостов, Warpgate, подключений на основе TOTP, SOCKS5, верификации ключей хоста, автозаполнения паролей, [OPKSSH](https://github.com/openpubkey/opkssh) и др.
|
||||
- **Богатый функционал SSH** — Поддержка jump-хостов, Warpgate, подключений на основе TOTP, SOCKS5, верификации ключей хоста, автозаполнения паролей, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking и др.
|
||||
- **Сетевой граф** — Настройте панель управления для визуализации вашей домашней лаборатории на основе SSH-подключений с поддержкой статусов
|
||||
- **Постоянные вкладки** — SSH-сессии и вкладки остаются открытыми на всех устройствах/при обновлении страницы, если включено в профиле пользователя
|
||||
|
||||
@@ -102,7 +87,7 @@ Termix — это платформа для управления сервера
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
Посетите [документацию](https://docs.termix.site/install) Termix для получения дополнительной информации об установке Termix на всех платформах. Также вы можете ознакомиться с примером файла Docker Compose здесь:
|
||||
Посетите [документацию](https://docs.termix.site/install) Termix для получения дополнительной информации об установке Termix на всех платформах. Также вы можете ознакомиться с примером файла Docker Compose здесь (вы можете опустить guacd и сеть, если не планируете использовать функции удаленного рабочего стола):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -122,7 +107,7 @@ services:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -151,11 +136,23 @@ networks:
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Crowdin">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Crowdin">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -170,26 +167,26 @@ networks:
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||
<img src="../repo-images/Image%202.png" width="400" alt="Termix Demo 2"/>
|
||||
<img src="../repo-images/Image 2.png" width="400" alt="Termix Demo 2"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%203.png" width="400" alt="Termix Demo 3"/>
|
||||
<img src="../repo-images/Image%204.png" width="400" alt="Termix Demo 4"/>
|
||||
<img src="../repo-images/Image 3.png" width="400" alt="Termix Demo 3"/>
|
||||
<img src="../repo-images/Image 4.png" width="400" alt="Termix Demo 4"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%205.png" width="400" alt="Termix Demo 5"/>
|
||||
<img src="../repo-images/Image%206.png" width="400" alt="Termix Demo 6"/>
|
||||
<img src="../repo-images/Image 5.png" width="400" alt="Termix Demo 5"/>
|
||||
<img src="../repo-images/Image 6.png" width="400" alt="Termix Demo 6"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%207.png" width="400" alt="Termix Demo 7"/>
|
||||
<img src="../repo-images/Image%208.png" width="400" alt="Termix Demo 8"/>
|
||||
<img src="../repo-images/Image 7.png" width="400" alt="Termix Demo 7"/>
|
||||
<img src="../repo-images/Image 8.png" width="400" alt="Termix Demo 8"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%209.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%2010.png" width="400" alt="Termix Demo 10"/>
|
||||
</p>
|
||||
|
||||
|
||||
+33
-36
@@ -1,20 +1,7 @@
|
||||
# Repo İstatistikleri
|
||||
|
||||
<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-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>
|
||||
<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>
|
||||
</p>
|
||||
|
||||

|
||||
@@ -34,9 +21,6 @@
|
||||
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||
</p>
|
||||
|
||||
Projeyi desteklemek isterseniz, buradan destek olabilirsiniz!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# Genel Bakış
|
||||
|
||||
<p align="center">
|
||||
@@ -44,31 +28,32 @@ Projeyi desteklemek isterseniz, buradan destek olabilirsiniz!\
|
||||
<img alt="Termix Banner" src=../public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||
</p>
|
||||
|
||||
Termix, açık kaynaklı, sonsuza kadar ücretsiz, kendi sunucunuzda barındırabileceğiniz hepsi bir arada sunucu yönetim platformudur. Sunucularınızı ve altyapınızı tek bir sezgisel arayüz üzerinden yönetmek için çok platformlu bir çözüm sunar. Termix, SSH terminal erişimi, SSH tünelleme yetenekleri, uzak dosya yönetimi ve daha birçok araç sağlar. Termix, tüm platformlarda kullanılabilen Termius'un mükemmel ücretsiz ve kendi barındırmalı alternatifidir.
|
||||
Termix, açık kaynaklı, sonsuza kadar ücretsiz, kendi sunucunuzda barındırabileceğiniz hepsi bir arada sunucu yönetim platformudur. Sunucularınızı ve altyapınızı tek bir sezgisel arayüz üzerinden yönetmek için çok platformlu bir çözüm sunar. Termix, SSH terminal erişimi, uzak masaüstü kontrolü (RDP, VNC, Telnet), SSH tünelleme yetenekleri, uzak SSH dosya yönetimi ve daha birçok araç sağlar. Termix, tüm platformlarda kullanılabilen Termius'un mükemmel ücretsiz ve kendi barındırmalı alternatifidir.
|
||||
|
||||
# Özellikler
|
||||
|
||||
- **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
|
||||
- **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.
|
||||
- **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
|
||||
- **Sunucu İstatistikleri** - Çoğu Linux tabanlı sunucularda CPU, bellek ve disk kullanımını ağ, çalışma süresi, sistem bilgisi, güvenlik duvarı, port izleme ile birlikte görüntüleyin
|
||||
- **Kontrol Paneli** - Kontrol panelinizde sunucu bilgilerini bir bakışta görüntüleyin
|
||||
- **RBAC** - Roller oluşturun ve ana bilgisayarları kullanıcılar/roller arasında paylaşın
|
||||
- **Kullanıcı Kimlik Doğrulama** - Yönetici kontrolleri, OIDC 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.
|
||||
- **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
|
||||
- **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. Karanlık veya açık tema arasında 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.
|
||||
- **Diller** - ~30 dil için yerleşik destek ([Crowdin](https://docs.termix.site/translations) tarafından yönetilir)
|
||||
- **Platform Desteği** - Web uygulaması, masaüstü uygulaması (Windows, Linux ve macOS), PWA ve iOS ile Android için özel mobil/tablet uygulaması olarak kullanılabilir.
|
||||
- **Platform Desteği** - Web uygulaması, masaüstü uygulaması (Windows, Linux ve macOS, Termix arka ucu olmadan tek başına çalıştırılabilir), PWA ve iOS ile Android için özel mobil/tablet uygulaması olarak kullanılabilir.
|
||||
- **SSH Araçları** - Tek tıklamayla çalıştırılan yeniden kullanılabilir komut parçacıkları oluşturun. Birden fazla açık terminalde aynı anda tek bir komut çalıştırın.
|
||||
- **Komut Geçmişi** - Daha önce çalıştırılan SSH komutlarını otomatik tamamlayın ve görüntüleyin
|
||||
- **Hızlı Bağlantı** - Bağlantı verilerini kaydetmeden bir sunucuya bağlanın
|
||||
- **Komut Paleti** - Sol shift tuşuna iki kez basarak SSH bağlantılarına klavyenizle hızlıca erişin
|
||||
- **SSH Zengin Özellikler** - Atlama ana bilgisayarları, Warpgate, TOTP tabanlı bağlantılar, SOCKS5, ana bilgisayar anahtar doğrulama, otomatik şifre doldurma, [OPKSSH](https://github.com/openpubkey/opkssh) vb. destekler.
|
||||
- **SSH Zengin Özellikler** - Atlama ana bilgisayarları, Warpgate, TOTP tabanlı bağlantılar, SOCKS5, ana bilgisayar anahtar doğrulama, otomatik şifre doldurma, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking vb. destekler.
|
||||
- **Ağ Grafiği** - Kontrol panelinizi, SSH bağlantılarınıza dayalı olarak ev laboratuvarınızı durum desteğiyle görselleştirmek için özelleştirin
|
||||
- **Kalıcı Sekmeler** - Kullanıcı profilinde etkinleştirilmişse SSH oturumları ve sekmeler cihazlar/yenilemeler arasında açık kalır
|
||||
|
||||
@@ -102,7 +87,7 @@ Desteklenen Cihazlar:
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
Termix'i tüm platformlara nasıl kuracağınız hakkında daha fazla bilgi için Termix [Belgelerine](https://docs.termix.site/install) bakın. Aksi takdirde, örnek bir Docker Compose dosyasını burada görüntüleyin:
|
||||
Termix'i tüm platformlara nasıl kuracağınız hakkında daha fazla bilgi için Termix [Belgelerine](https://docs.termix.site/install) bakın. Aksi takdirde, örnek bir Docker Compose dosyasını burada görüntüleyin (uzak masaüstü özelliklerini kullanmayı planlamıyorsanız guacd'yi ve ağı çıkarabilirsiniz):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -122,7 +107,7 @@ services:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -151,11 +136,23 @@ networks:
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Crowdin">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Crowdin">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -170,26 +167,26 @@ Lütfen sorununuzu mümkün olduğunca ayrıntılı yazın, tercihen İngilizce
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||
<img src="../repo-images/Image%202.png" width="400" alt="Termix Demo 2"/>
|
||||
<img src="../repo-images/Image 2.png" width="400" alt="Termix Demo 2"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%203.png" width="400" alt="Termix Demo 3"/>
|
||||
<img src="../repo-images/Image%204.png" width="400" alt="Termix Demo 4"/>
|
||||
<img src="../repo-images/Image 3.png" width="400" alt="Termix Demo 3"/>
|
||||
<img src="../repo-images/Image 4.png" width="400" alt="Termix Demo 4"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%205.png" width="400" alt="Termix Demo 5"/>
|
||||
<img src="../repo-images/Image%206.png" width="400" alt="Termix Demo 6"/>
|
||||
<img src="../repo-images/Image 5.png" width="400" alt="Termix Demo 5"/>
|
||||
<img src="../repo-images/Image 6.png" width="400" alt="Termix Demo 6"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%207.png" width="400" alt="Termix Demo 7"/>
|
||||
<img src="../repo-images/Image%208.png" width="400" alt="Termix Demo 8"/>
|
||||
<img src="../repo-images/Image 7.png" width="400" alt="Termix Demo 7"/>
|
||||
<img src="../repo-images/Image 8.png" width="400" alt="Termix Demo 8"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%209.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%2010.png" width="400" alt="Termix Demo 10"/>
|
||||
</p>
|
||||
|
||||
|
||||
+33
-36
@@ -1,20 +1,7 @@
|
||||
# Thống Kê Repo
|
||||
|
||||
<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-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>
|
||||
<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>
|
||||
</p>
|
||||
|
||||

|
||||
@@ -34,9 +21,6 @@
|
||||
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||
</p>
|
||||
|
||||
Nếu bạn muốn, bạn có thể hỗ trợ dự án tại đây!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# Tổng Quan
|
||||
|
||||
<p align="center">
|
||||
@@ -44,31 +28,32 @@ Nếu bạn muốn, bạn có thể hỗ trợ dự án tại đây!\
|
||||
<img alt="Termix Banner" src=../public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||
</p>
|
||||
|
||||
Termix là nền tảng quản lý máy chủ tất cả trong một, mã nguồn mở, miễn phí vĩnh viễn, tự lưu trữ. Nó cung cấp giải pháp đa nền tảng để quản lý máy chủ và cơ sở hạ tầng của bạn thông qua một giao diện trực quan duy nhất. Termix cung cấp quyền truy cập terminal SSH, khả năng tạo đường hầm SSH, quản lý tệp từ xa và nhiều công cụ khác. Termix là giải pháp thay thế miễn phí và tự lưu trữ hoàn hảo cho Termius, khả dụng trên tất cả các nền tảng.
|
||||
Termix là nền tảng quản lý máy chủ tất cả trong một, mã nguồn mở, miễn phí vĩnh viễn, tự lưu trữ. Nó cung cấp giải pháp đa nền tảng để quản lý máy chủ và cơ sở hạ tầng của bạn thông qua một giao diện trực quan duy nhất. Termix cung cấp quyền truy cập terminal SSH, điều khiển máy tính từ xa (RDP, VNC, Telnet), khả năng tạo đường hầm SSH, quản lý tệp SSH từ xa và nhiều công cụ khác. Termix là giải pháp thay thế miễn phí và tự lưu trữ hoàn hảo cho Termius, khả dụng trên tất cả các nền tảng.
|
||||
|
||||
# Tính Năng
|
||||
|
||||
- **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
|
||||
- **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 và 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.
|
||||
- **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
|
||||
- **Thống Kê Máy Chủ** - Xem mức sử dụng CPU, bộ nhớ và ổ đĩa cùng với mạng, thời gian hoạt động, thông tin hệ thống, tường lửa, giám sát cổng trên hầu hết các máy chủ chạy Linux
|
||||
- **Bảng Điều Khiển** - Xem thông tin máy chủ trong nháy mắt trên bảng điều khiển của bạn
|
||||
- **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 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.
|
||||
- **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
|
||||
- **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 giao diện chế độ tối hoặc sáng. 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.
|
||||
- **Ngôn Ngữ** - Hỗ trợ tích hợp ~30 ngôn ngữ (được quản lý bởi [Crowdin](https://docs.termix.site/translations))
|
||||
- **Hỗ Trợ Nền Tảng** - Khả dụng dưới dạng ứng dụng web, ứng dụng máy tính (Windows, Linux và macOS), PWA và ứng dụng di động/máy tính bảng chuyên dụng cho iOS và Android.
|
||||
- **Hỗ Trợ Nền Tảng** - Khả dụng dưới dạng ứng dụng web, ứng dụng máy tính (Windows, Linux và macOS, có thể chạy độc lập mà không cần backend Termix), PWA và ứng dụng di động/máy tính bảng chuyên dụng cho iOS và Android.
|
||||
- **Công Cụ SSH** - Tạo đoạn lệnh có thể tái sử dụng, thực thi chỉ với một cú nhấp chuột. Chạy một lệnh đồng thời trên nhiều terminal đang mở.
|
||||
- **Lịch Sử Lệnh** - Tự động hoàn thành và xem các lệnh SSH đã chạy trước đó
|
||||
- **Kết Nối Nhanh** - Kết nối đến máy chủ mà không cần lưu dữ liệu kết nối
|
||||
- **Bảng Lệnh** - Nhấn đúp phím shift trái để truy cập nhanh các kết nối SSH bằng bàn phím
|
||||
- **SSH Giàu Tính Năng** - Hỗ trợ jump host, Warpgate, kết nối dựa trên TOTP, SOCKS5, xác minh khóa máy chủ, tự động điền mật khẩu, [OPKSSH](https://github.com/openpubkey/opkssh), v.v.
|
||||
- **SSH Giàu Tính Năng** - Hỗ trợ jump host, Warpgate, kết nối dựa trên TOTP, SOCKS5, xác minh khóa máy chủ, tự động điền mật khẩu, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, v.v.
|
||||
- **Biểu Đồ Mạng** - Tùy chỉnh Bảng Điều Khiển để trực quan hóa homelab của bạn dựa trên các kết nối SSH với hỗ trợ trạng thái
|
||||
- **Tab Liên Tục** - Các phiên SSH và tab vẫn mở trên các thiết bị/lần làm mới nếu được bật trong hồ sơ người dùng
|
||||
|
||||
@@ -102,7 +87,7 @@ Thiết Bị Được Hỗ Trợ:
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
Truy cập [Tài Liệu](https://docs.termix.site/install) Termix để biết thêm thông tin về cách cài đặt Termix trên tất cả các nền tảng. Ngoài ra, xem tệp Docker Compose mẫu tại đây:
|
||||
Truy cập [Tài Liệu](https://docs.termix.site/install) Termix để biết thêm thông tin về cách cài đặt Termix trên tất cả các nền tảng. Ngoài ra, xem tệp Docker Compose mẫu tại đây (bạn có thể bỏ qua guacd và mạng nếu không có ý định sử dụng các tính năng điều khiển máy tính từ xa):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -122,7 +107,7 @@ services:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -151,11 +136,23 @@ networks:
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Crowdin">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Crowdin">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -170,26 +167,26 @@ 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">
|
||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||
<img src="../repo-images/Image%202.png" width="400" alt="Termix Demo 2"/>
|
||||
<img src="../repo-images/Image 2.png" width="400" alt="Termix Demo 2"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%203.png" width="400" alt="Termix Demo 3"/>
|
||||
<img src="../repo-images/Image%204.png" width="400" alt="Termix Demo 4"/>
|
||||
<img src="../repo-images/Image 3.png" width="400" alt="Termix Demo 3"/>
|
||||
<img src="../repo-images/Image 4.png" width="400" alt="Termix Demo 4"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%205.png" width="400" alt="Termix Demo 5"/>
|
||||
<img src="../repo-images/Image%206.png" width="400" alt="Termix Demo 6"/>
|
||||
<img src="../repo-images/Image 5.png" width="400" alt="Termix Demo 5"/>
|
||||
<img src="../repo-images/Image 6.png" width="400" alt="Termix Demo 6"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%207.png" width="400" alt="Termix Demo 7"/>
|
||||
<img src="../repo-images/Image%208.png" width="400" alt="Termix Demo 8"/>
|
||||
<img src="../repo-images/Image 7.png" width="400" alt="Termix Demo 7"/>
|
||||
<img src="../repo-images/Image 8.png" width="400" alt="Termix Demo 8"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%209.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%2010.png" width="400" alt="Termix Demo 10"/>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -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) {`,
|
||||
},
|
||||
]);
|
||||
@@ -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}`);
|
||||
+43
-46
@@ -1,6 +1,6 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { createCorsMiddleware } from "./utils/cors-config.js";
|
||||
import { getDb, DatabaseSaveTrigger } from "./database/db/index.js";
|
||||
import {
|
||||
recentActivity,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
hostAccess,
|
||||
dashboardPreferences,
|
||||
} from "./database/db/schema.js";
|
||||
import { eq, and, desc, sql } from "drizzle-orm";
|
||||
import { eq, and, desc, sql, inArray } from "drizzle-orm";
|
||||
import { dashboardLogger } from "./utils/logger.js";
|
||||
import { SimpleDBOps } from "./utils/simple-db-ops.js";
|
||||
import { AuthManager } from "./utils/auth-manager.js";
|
||||
@@ -22,37 +22,7 @@ const serverStartTime = Date.now();
|
||||
const activityRateLimiter = new Map<string, number>();
|
||||
const RATE_LIMIT_MS = 1000;
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
const allowedOrigins = ["http://localhost:5173", "http://127.0.0.1:5173"];
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("https://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("http://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"X-Electron-App",
|
||||
],
|
||||
}),
|
||||
);
|
||||
app.use(createCorsMiddleware());
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use((_req, res, next) => {
|
||||
@@ -288,21 +258,48 @@ app.post("/activity/log", async (req, res) => {
|
||||
userId,
|
||||
)) as unknown as { id: number };
|
||||
|
||||
const allActivities = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId))
|
||||
.orderBy(desc(recentActivity.timestamp)),
|
||||
"recent_activity",
|
||||
userId,
|
||||
);
|
||||
// Best-effort trim of old activity entries; failures here should not
|
||||
// cause the primary /activity/log request to 500.
|
||||
try {
|
||||
const allActivities = await SimpleDBOps.select<{
|
||||
id: number;
|
||||
timestamp: string;
|
||||
}>(
|
||||
getDb()
|
||||
.select({
|
||||
id: recentActivity.id,
|
||||
timestamp: recentActivity.timestamp,
|
||||
})
|
||||
.from(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId))
|
||||
.orderBy(desc(recentActivity.timestamp)),
|
||||
"recent_activity",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (allActivities.length > 100) {
|
||||
const toDelete = allActivities.slice(100);
|
||||
for (let i = 0; i < toDelete.length; i++) {
|
||||
await SimpleDBOps.delete(recentActivity, "recent_activity", userId);
|
||||
if (allActivities.length > 100) {
|
||||
const idsToDelete = allActivities
|
||||
.slice(100)
|
||||
.map((a) => a.id)
|
||||
.filter((id) => typeof id === "number");
|
||||
|
||||
if (idsToDelete.length > 0) {
|
||||
await SimpleDBOps.delete(
|
||||
recentActivity,
|
||||
"recent_activity",
|
||||
and(
|
||||
eq(recentActivity.userId, userId),
|
||||
inArray(recentActivity.id, idsToDelete),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (trimErr) {
|
||||
dashboardLogger.warn("Failed to trim recent_activity (non-fatal)", {
|
||||
operation: "trim_recent_activity",
|
||||
userId,
|
||||
error: trimErr instanceof Error ? trimErr.message : String(trimErr),
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: "Activity logged", id: result.id });
|
||||
|
||||
+142
-107
@@ -1,4 +1,5 @@
|
||||
import express from "express";
|
||||
import http from "http";
|
||||
import bodyParser from "body-parser";
|
||||
import multer from "multer";
|
||||
import cookieParser from "cookie-parser";
|
||||
@@ -7,12 +8,12 @@ import hostRoutes from "./routes/host.js";
|
||||
import alertRoutes from "./routes/alerts.js";
|
||||
import credentialsRoutes from "./routes/credentials.js";
|
||||
import snippetsRoutes from "./routes/snippets.js";
|
||||
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
|
||||
import terminalRoutes from "./routes/terminal.js";
|
||||
import guacamoleRoutes from "../guacamole/routes.js";
|
||||
import networkTopologyRoutes from "./routes/network-topology.js";
|
||||
import rbacRoutes from "./routes/rbac.js";
|
||||
import cors from "cors";
|
||||
import fetch from "node-fetch";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
@@ -58,39 +59,7 @@ app.set("trust proxy", true);
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireAdmin = authManager.createAdminMiddleware();
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
const allowedOrigins = ["http://localhost:5173", "http://127.0.0.1:5173"];
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("https://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("http://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"X-Electron-App",
|
||||
"Accept",
|
||||
"Origin",
|
||||
],
|
||||
}),
|
||||
);
|
||||
app.use(createCorsMiddleware());
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
@@ -149,6 +118,31 @@ class 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 REPO_OWNER = "Termix-SSH";
|
||||
const REPO_NAME = "Termix";
|
||||
@@ -174,7 +168,7 @@ async function fetchGitHubAPI<T>(
|
||||
"User-Agent": "TermixUpdateChecker/1.0",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
agent: getProxyAgent(url),
|
||||
dispatcher: getProxyAgent(url),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -307,6 +301,10 @@ app.get("/version", authenticateJWT, async (req, res) => {
|
||||
return res.status(404).send("Local Version Not Set");
|
||||
}
|
||||
|
||||
if (req.query.checkRemote === "false") {
|
||||
return res.json({ localVersion, status: "update_check_disabled" });
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = "latest_release";
|
||||
const releaseData = await fetchGitHubAPI<GitHubRelease>(
|
||||
@@ -326,12 +324,19 @@ app.get("/version", authenticateJWT, async (req, res) => {
|
||||
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 = {
|
||||
status: isUpToDate ? "up_to_date" : "requires_update",
|
||||
status,
|
||||
localVersion: localVersion,
|
||||
version: remoteVersion,
|
||||
remoteVersion: remoteVersion,
|
||||
latest_release: {
|
||||
tag_name: releaseData.data.tag_name,
|
||||
name: releaseData.data.name,
|
||||
@@ -603,7 +608,6 @@ app.post("/encryption/regenerate-jwt", requireAdmin, async (req, res) => {
|
||||
app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { password } = req.body;
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
|
||||
const user = await getDb().select().from(users).where(eq(users.id, userId));
|
||||
@@ -613,30 +617,20 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
|
||||
const isOidcUser = !!user[0].isOidc;
|
||||
|
||||
if (!isOidcUser) {
|
||||
if (!password) {
|
||||
return res.status(400).json({
|
||||
error: "Password required for export",
|
||||
code: "PASSWORD_REQUIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const unlocked = await authManager.authenticateUser(
|
||||
userId,
|
||||
password,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (!unlocked) {
|
||||
return res.status(401).json({ error: "Invalid password" });
|
||||
}
|
||||
} else if (!DataCrypto.getUserDataKey(userId)) {
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(
|
||||
userId,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (!oidcUnlocked) {
|
||||
if (!DataCrypto.getUserDataKey(userId)) {
|
||||
if (isOidcUser) {
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(
|
||||
userId,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (!oidcUnlocked) {
|
||||
return res.status(403).json({
|
||||
error: "Failed to unlock user data with SSO credentials",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return res.status(403).json({
|
||||
error: "Failed to unlock user data with SSO credentials",
|
||||
error: "User data is locked. Please log in again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -651,10 +645,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
throw new Error("User data not unlocked");
|
||||
}
|
||||
|
||||
const tempDir =
|
||||
process.env.NODE_ENV === "production"
|
||||
? path.join(process.env.DATA_DIR || "./db/data", ".temp", "exports")
|
||||
: path.join(os.tmpdir(), "termix-exports");
|
||||
const tempDir = path.join(os.tmpdir(), "termix-exports");
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
@@ -665,7 +656,9 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
operation: "export_temp_dir_error",
|
||||
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, "-");
|
||||
@@ -710,6 +703,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
connection_type TEXT NOT NULL DEFAULT 'ssh',
|
||||
name TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
@@ -742,6 +736,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
default_path TEXT,
|
||||
stats_config TEXT,
|
||||
docker_config TEXT,
|
||||
terminal_config TEXT,
|
||||
quick_actions TEXT,
|
||||
notes TEXT,
|
||||
@@ -751,6 +746,12 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
socks5_username TEXT,
|
||||
socks5_password TEXT,
|
||||
socks5_proxy_chain TEXT,
|
||||
domain TEXT,
|
||||
security TEXT,
|
||||
ignore_cert INTEGER NOT NULL DEFAULT 0,
|
||||
guacamole_config TEXT,
|
||||
mac_address TEXT,
|
||||
port_knock_sequence TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -763,7 +764,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
auth_type TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
username TEXT,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
private_key TEXT,
|
||||
@@ -850,8 +851,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
.from(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
const insertHost = exportDb.prepare(`
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_docker, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, stats_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_docker, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, stats_config, docker_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, security, ignore_cert, guacamole_config, mac_address, port_knock_sequence, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const host of sshHosts) {
|
||||
@@ -864,6 +865,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
insertHost.run(
|
||||
decrypted.id,
|
||||
decrypted.userId,
|
||||
decrypted.connectionType || "ssh",
|
||||
decrypted.name || null,
|
||||
decrypted.ip,
|
||||
decrypted.port,
|
||||
@@ -896,6 +898,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
decrypted.showServerStatsInSidebar ? 1 : 0,
|
||||
decrypted.defaultPath || null,
|
||||
decrypted.statsConfig || null,
|
||||
decrypted.dockerConfig || null,
|
||||
decrypted.terminalConfig || null,
|
||||
decrypted.quickActions || null,
|
||||
decrypted.notes || null,
|
||||
@@ -905,6 +908,12 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
decrypted.socks5Username || null,
|
||||
decrypted.socks5Password || null,
|
||||
decrypted.socks5ProxyChain || null,
|
||||
decrypted.domain || null,
|
||||
decrypted.security || null,
|
||||
decrypted.ignoreCert ? 1 : 0,
|
||||
decrypted.guacamoleConfig || null,
|
||||
decrypted.macAddress || null,
|
||||
decrypted.portKnockSequence || null,
|
||||
decrypted.createdAt,
|
||||
decrypted.updatedAt,
|
||||
);
|
||||
@@ -1146,7 +1155,6 @@ app.post(
|
||||
}
|
||||
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { password } = req.body;
|
||||
const mainDb = getDb();
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
|
||||
@@ -1161,30 +1169,20 @@ app.post(
|
||||
|
||||
const isOidcUser = !!userRecords[0].isOidc;
|
||||
|
||||
if (!isOidcUser) {
|
||||
if (!password) {
|
||||
return res.status(400).json({
|
||||
error: "Password required for import",
|
||||
code: "PASSWORD_REQUIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const unlocked = await authManager.authenticateUser(
|
||||
userId,
|
||||
password,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (!unlocked) {
|
||||
return res.status(401).json({ error: "Invalid password" });
|
||||
}
|
||||
} else if (!DataCrypto.getUserDataKey(userId)) {
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(
|
||||
userId,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (!oidcUnlocked) {
|
||||
if (!DataCrypto.getUserDataKey(userId)) {
|
||||
if (isOidcUser) {
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(
|
||||
userId,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (!oidcUnlocked) {
|
||||
return res.status(403).json({
|
||||
error: "Failed to unlock user data with SSO credentials",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return res.status(403).json({
|
||||
error: "Failed to unlock user data with SSO credentials",
|
||||
error: "User data is locked. Please log in again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1197,16 +1195,7 @@ app.post(
|
||||
mimetype: req.file.mimetype,
|
||||
});
|
||||
|
||||
let userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey && isOidcUser) {
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(
|
||||
userId,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (oidcUnlocked) {
|
||||
userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
}
|
||||
}
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
throw new Error("User data not unlocked");
|
||||
}
|
||||
@@ -1764,6 +1753,7 @@ app.use("/host", hostRoutes);
|
||||
app.use("/alerts", alertRoutes);
|
||||
app.use("/credentials", credentialsRoutes);
|
||||
app.use("/snippets", snippetsRoutes);
|
||||
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
|
||||
app.use("/terminal", terminalRoutes);
|
||||
app.use("/guacamole", guacamoleRoutes);
|
||||
app.use("/network-topology", networkTopologyRoutes);
|
||||
@@ -1783,10 +1773,38 @@ if (frontendDist) {
|
||||
databaseLogger.info(`Serving frontend from: ${frontendDist}`, {
|
||||
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) => {
|
||||
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"));
|
||||
} else {
|
||||
next();
|
||||
@@ -1795,13 +1813,13 @@ if (frontendDist) {
|
||||
}
|
||||
|
||||
app.use(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
(
|
||||
err: unknown,
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
_next: express.NextFunction,
|
||||
) => {
|
||||
void _next;
|
||||
apiLogger.error("Unhandled error in request", err, {
|
||||
operation: "error_handler",
|
||||
method: req.method,
|
||||
@@ -1982,7 +2000,24 @@ app.get(
|
||||
},
|
||||
);
|
||||
|
||||
app.listen(HTTP_PORT, async () => {
|
||||
const httpServer = http.createServer(app);
|
||||
|
||||
httpServer.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
databaseLogger.error(
|
||||
`Port ${HTTP_PORT} is already in use. Kill the existing process and retry.`,
|
||||
err,
|
||||
{
|
||||
operation: "http_server_port_conflict",
|
||||
port: HTTP_PORT,
|
||||
},
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
httpServer.listen(HTTP_PORT, async () => {
|
||||
const uploadsDir = path.join(process.cwd(), "uploads");
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
|
||||
@@ -118,6 +118,7 @@ async function initializeDatabaseAsync(): Promise<void> {
|
||||
|
||||
throw new Error(
|
||||
`Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -317,6 +318,18 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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
|
||||
);
|
||||
|
||||
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 {
|
||||
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) {
|
||||
databaseLogger.warn("Could not clear expired sessions on startup", {
|
||||
operation: "db_init_session_cleanup_failed",
|
||||
@@ -775,6 +809,59 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM snippet_access LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS snippet_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snippet_id INTEGER NOT NULL,
|
||||
user_id TEXT,
|
||||
role_id INTEGER,
|
||||
granted_by TEXT NOT NULL,
|
||||
permission_level TEXT NOT NULL DEFAULT 'view',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (snippet_id) REFERENCES snippets (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create snippet_access table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
sqlite
|
||||
.prepare("SELECT id FROM sessions LIMIT 1")
|
||||
@@ -933,6 +1020,47 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const sshDataMigrations: Array<{ column: string; sql: string }> = [
|
||||
{ column: "connection_type", sql: "ALTER TABLE ssh_data ADD COLUMN connection_type TEXT NOT NULL DEFAULT 'ssh'" },
|
||||
{ column: "credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN credential_id INTEGER" },
|
||||
{ column: "override_credential_username", sql: "ALTER TABLE ssh_data ADD COLUMN override_credential_username INTEGER" },
|
||||
{ column: "jump_hosts", sql: "ALTER TABLE ssh_data ADD COLUMN jump_hosts TEXT" },
|
||||
{ column: "show_terminal_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1" },
|
||||
{ column: "show_file_manager_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0" },
|
||||
{ column: "show_tunnel_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0" },
|
||||
{ column: "show_docker_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0" },
|
||||
{ column: "show_server_stats_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0" },
|
||||
{ column: "quick_actions", sql: "ALTER TABLE ssh_data ADD COLUMN quick_actions TEXT" },
|
||||
{ column: "domain", sql: "ALTER TABLE ssh_data ADD COLUMN domain TEXT" },
|
||||
{ column: "security", sql: "ALTER TABLE ssh_data ADD COLUMN security TEXT" },
|
||||
{ column: "ignore_cert", sql: "ALTER TABLE ssh_data ADD COLUMN ignore_cert INTEGER NOT NULL DEFAULT 0" },
|
||||
{ column: "guacamole_config", sql: "ALTER TABLE ssh_data ADD COLUMN guacamole_config TEXT" },
|
||||
{ column: "socks5_proxy_chain", sql: "ALTER TABLE ssh_data ADD COLUMN socks5_proxy_chain TEXT" },
|
||||
{ column: "host_key_fingerprint", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_fingerprint TEXT" },
|
||||
{ column: "host_key_type", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_type TEXT" },
|
||||
{ column: "host_key_algorithm", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_algorithm TEXT NOT NULL DEFAULT 'sha256'" },
|
||||
{ column: "host_key_first_seen", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_first_seen TEXT" },
|
||||
{ column: "host_key_last_verified", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_last_verified TEXT" },
|
||||
{ column: "host_key_changed_count", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_changed_count INTEGER NOT NULL DEFAULT 0" },
|
||||
{ column: "mac_address", sql: "ALTER TABLE ssh_data ADD COLUMN mac_address TEXT" },
|
||||
{ column: "port_knock_sequence", sql: "ALTER TABLE ssh_data ADD COLUMN port_knock_sequence TEXT" },
|
||||
];
|
||||
|
||||
for (const migration of sshDataMigrations) {
|
||||
try {
|
||||
sqlite.prepare(`SELECT ${migration.column} FROM ssh_data LIMIT 1`).get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(migration.sql);
|
||||
} catch (alterError) {
|
||||
databaseLogger.warn(`Failed to add ${migration.column} column`, {
|
||||
operation: "schema_migration",
|
||||
error: alterError,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM roles LIMIT 1").get();
|
||||
} catch {
|
||||
@@ -1106,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 {
|
||||
const existingRoles = sqlite.prepare("SELECT name, is_system FROM roles").all() as Array<{ name: string; is_system: number }>;
|
||||
|
||||
@@ -1216,7 +1370,7 @@ const migrateSchema = () => {
|
||||
});
|
||||
};
|
||||
|
||||
async function saveMemoryDatabaseToFile() {
|
||||
async function saveMemoryDatabaseToFile(): Promise<void> {
|
||||
if (!memoryDatabase) return;
|
||||
|
||||
try {
|
||||
|
||||
@@ -141,6 +141,9 @@ export const hosts = sqliteTable("ssh_data", {
|
||||
socks5Password: text("socks5_password"),
|
||||
socks5ProxyChain: text("socks5_proxy_chain"),
|
||||
|
||||
macAddress: text("mac_address"),
|
||||
portKnockSequence: text("port_knock_sequence"),
|
||||
|
||||
hostKeyFingerprint: text("host_key_fingerprint"),
|
||||
hostKeyType: text("host_key_type"),
|
||||
hostKeyAlgorithm: text("host_key_algorithm").default("sha256"),
|
||||
@@ -295,6 +298,47 @@ export const snippetFolders = sqliteTable("snippet_folders", {
|
||||
.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", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
snippetId: integer("snippet_id")
|
||||
.notNull()
|
||||
.references(() => snippets.id, { onDelete: "cascade" }),
|
||||
|
||||
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
|
||||
roleId: integer("role_id").references(() => roles.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
|
||||
grantedBy: text("granted_by")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
permissionLevel: text("permission_level").notNull().default("view"),
|
||||
|
||||
expiresAt: text("expires_at"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sshFolders = sqliteTable("ssh_folders", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
@@ -545,3 +589,17 @@ export const opksshTokens = sqliteTable("opkssh_tokens", {
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
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),
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { dismissedAlerts } from "../db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import fetch from "node-fetch";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { getProxyAgent } from "../../utils/proxy-agent.js";
|
||||
@@ -61,7 +60,7 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "TermixAlertChecker/1.0",
|
||||
},
|
||||
agent: getProxyAgent(url),
|
||||
dispatcher: getProxyAgent(url),
|
||||
});
|
||||
|
||||
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;
|
||||
+1093
-468
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@ import {
|
||||
roles,
|
||||
userRoles,
|
||||
sharedCredentials,
|
||||
snippets,
|
||||
snippetAccess,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and, desc, sql, or, isNull, gte } from "drizzle-orm";
|
||||
import type { Response } from "express";
|
||||
@@ -516,46 +518,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/roles:
|
||||
* get:
|
||||
* summary: Get all roles
|
||||
* description: Retrieves a list of all roles.
|
||||
* tags:
|
||||
* - RBAC
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of roles.
|
||||
* 500:
|
||||
* description: Failed to get roles.
|
||||
*/
|
||||
router.get(
|
||||
"/roles",
|
||||
authenticateJWT,
|
||||
permissionManager.requireAdmin(),
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const allRoles = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.orderBy(roles.isSystem, roles.name);
|
||||
|
||||
const rolesWithParsedPermissions = allRoles.map((role) => ({
|
||||
...role,
|
||||
permissions: JSON.parse(role.permissions),
|
||||
}));
|
||||
|
||||
res.json({ roles: rolesWithParsedPermissions });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get roles", error, {
|
||||
operation: "get_roles",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get roles" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/roles:
|
||||
@@ -1193,4 +1155,369 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// SNIPPET SHARING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/snippet/{id}/share:
|
||||
* post:
|
||||
* summary: Share a snippet
|
||||
* description: Shares a snippet with a user or role.
|
||||
* tags:
|
||||
* - RBAC
|
||||
*/
|
||||
router.post(
|
||||
"/snippet/:id/share",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const snippetId = parseInt(id, 10);
|
||||
const userId = req.userId!;
|
||||
|
||||
if (isNaN(snippetId)) {
|
||||
return res.status(400).json({ error: "Invalid snippet ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
targetType = "user",
|
||||
targetUserId,
|
||||
targetRoleId,
|
||||
durationHours,
|
||||
} = req.body;
|
||||
|
||||
if (!["user", "role"].includes(targetType)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid target type. Must be 'user' or 'role'" });
|
||||
}
|
||||
|
||||
if (targetType === "user" && !isNonEmptyString(targetUserId)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Target user ID is required when sharing with user" });
|
||||
}
|
||||
if (targetType === "role" && !targetRoleId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Target role ID is required when sharing with role" });
|
||||
}
|
||||
|
||||
const snippet = await db
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (snippet.length === 0) {
|
||||
return res.status(403).json({ error: "Not snippet owner" });
|
||||
}
|
||||
|
||||
if (targetType === "user") {
|
||||
const targetUser = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.id, targetUserId))
|
||||
.limit(1);
|
||||
if (targetUser.length === 0) {
|
||||
return res.status(404).json({ error: "Target user not found" });
|
||||
}
|
||||
} else {
|
||||
const targetRole = await db
|
||||
.select({ id: roles.id })
|
||||
.from(roles)
|
||||
.where(eq(roles.id, targetRoleId))
|
||||
.limit(1);
|
||||
if (targetRole.length === 0) {
|
||||
return res.status(404).json({ error: "Target role not found" });
|
||||
}
|
||||
}
|
||||
|
||||
let expiresAt: string | null = null;
|
||||
if (
|
||||
durationHours &&
|
||||
typeof durationHours === "number" &&
|
||||
durationHours > 0
|
||||
) {
|
||||
const expiryDate = new Date();
|
||||
expiryDate.setHours(expiryDate.getHours() + durationHours);
|
||||
expiresAt = expiryDate.toISOString();
|
||||
}
|
||||
|
||||
const whereConditions = [eq(snippetAccess.snippetId, snippetId)];
|
||||
if (targetType === "user") {
|
||||
whereConditions.push(eq(snippetAccess.userId, targetUserId));
|
||||
} else {
|
||||
whereConditions.push(eq(snippetAccess.roleId, targetRoleId));
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(snippetAccess)
|
||||
.where(and(...whereConditions))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(snippetAccess)
|
||||
.set({ expiresAt })
|
||||
.where(eq(snippetAccess.id, existing[0].id));
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "Snippet access updated",
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
await db.insert(snippetAccess).values({
|
||||
snippetId,
|
||||
userId: targetType === "user" ? targetUserId : null,
|
||||
roleId: targetType === "role" ? targetRoleId : null,
|
||||
grantedBy: userId,
|
||||
permissionLevel: "view",
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
databaseLogger.success("Snippet shared successfully", {
|
||||
operation: "rbac_snippet_share",
|
||||
userId,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Snippet shared successfully with ${targetType}`,
|
||||
expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to share snippet", error, {
|
||||
operation: "share_snippet",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to share snippet" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/snippet/{id}/access/{accessId}:
|
||||
* delete:
|
||||
* summary: Revoke snippet access
|
||||
* description: Revokes a user's or role's access to a snippet.
|
||||
* tags:
|
||||
* - RBAC
|
||||
*/
|
||||
router.delete(
|
||||
"/snippet/:id/access/:accessId",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const accessIdParam = Array.isArray(req.params.accessId)
|
||||
? req.params.accessId[0]
|
||||
: req.params.accessId;
|
||||
const snippetId = parseInt(id, 10);
|
||||
const accessId = parseInt(accessIdParam, 10);
|
||||
const userId = req.userId!;
|
||||
|
||||
if (isNaN(snippetId) || isNaN(accessId)) {
|
||||
return res.status(400).json({ error: "Invalid ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const snippet = await db
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (snippet.length === 0) {
|
||||
return res.status(403).json({ error: "Not snippet owner" });
|
||||
}
|
||||
|
||||
await db.delete(snippetAccess).where(eq(snippetAccess.id, accessId));
|
||||
|
||||
res.json({ success: true, message: "Snippet access revoked" });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to revoke snippet access", error, {
|
||||
operation: "revoke_snippet_access",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to revoke access" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/snippet/{id}/access:
|
||||
* get:
|
||||
* summary: Get snippet access list
|
||||
* description: Retrieves the list of users and roles with access to a snippet.
|
||||
* tags:
|
||||
* - RBAC
|
||||
*/
|
||||
router.get(
|
||||
"/snippet/:id/access",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const snippetId = parseInt(id, 10);
|
||||
const userId = req.userId!;
|
||||
|
||||
if (isNaN(snippetId)) {
|
||||
return res.status(400).json({ error: "Invalid snippet ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const snippet = await db
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (snippet.length === 0) {
|
||||
return res.status(403).json({ error: "Not snippet owner" });
|
||||
}
|
||||
|
||||
const rawAccessList = await db
|
||||
.select({
|
||||
id: snippetAccess.id,
|
||||
userId: snippetAccess.userId,
|
||||
roleId: snippetAccess.roleId,
|
||||
username: users.username,
|
||||
roleName: roles.name,
|
||||
roleDisplayName: roles.displayName,
|
||||
grantedBy: snippetAccess.grantedBy,
|
||||
grantedByUsername: sql<string>`(SELECT username FROM users WHERE id = ${snippetAccess.grantedBy})`,
|
||||
permissionLevel: snippetAccess.permissionLevel,
|
||||
expiresAt: snippetAccess.expiresAt,
|
||||
createdAt: snippetAccess.createdAt,
|
||||
})
|
||||
.from(snippetAccess)
|
||||
.leftJoin(users, eq(snippetAccess.userId, users.id))
|
||||
.leftJoin(roles, eq(snippetAccess.roleId, roles.id))
|
||||
.where(eq(snippetAccess.snippetId, snippetId))
|
||||
.orderBy(desc(snippetAccess.createdAt));
|
||||
|
||||
const accessList = rawAccessList.map((access) => ({
|
||||
id: access.id,
|
||||
targetType: access.userId ? "user" : "role",
|
||||
userId: access.userId,
|
||||
roleId: access.roleId,
|
||||
username: access.username,
|
||||
roleName: access.roleName,
|
||||
roleDisplayName: access.roleDisplayName,
|
||||
grantedBy: access.grantedBy,
|
||||
grantedByUsername: access.grantedByUsername,
|
||||
permissionLevel: access.permissionLevel,
|
||||
expiresAt: access.expiresAt,
|
||||
createdAt: access.createdAt,
|
||||
}));
|
||||
|
||||
res.json({ accessList });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get snippet access list", error, {
|
||||
operation: "get_snippet_access_list",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get access list" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/shared-snippets:
|
||||
* get:
|
||||
* summary: Get shared snippets
|
||||
* description: Retrieves snippets shared with the current user.
|
||||
* tags:
|
||||
* - RBAC
|
||||
*/
|
||||
router.get(
|
||||
"/shared-snippets",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userId = req.userId!;
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const directShared = await db
|
||||
.select({
|
||||
id: snippets.id,
|
||||
name: snippets.name,
|
||||
content: snippets.content,
|
||||
description: snippets.description,
|
||||
folder: snippets.folder,
|
||||
ownerUsername: users.username,
|
||||
permissionLevel: snippetAccess.permissionLevel,
|
||||
expiresAt: snippetAccess.expiresAt,
|
||||
})
|
||||
.from(snippetAccess)
|
||||
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
|
||||
.innerJoin(users, eq(snippets.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
eq(snippetAccess.userId, userId),
|
||||
or(
|
||||
isNull(snippetAccess.expiresAt),
|
||||
gte(snippetAccess.expiresAt, now),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const userRoleRows = await db
|
||||
.select({ roleId: userRoles.roleId })
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.userId, userId));
|
||||
const roleIds = userRoleRows.map((r) => r.roleId);
|
||||
|
||||
let roleShared: typeof directShared = [];
|
||||
if (roleIds.length > 0) {
|
||||
const directIds = directShared.map((s) => s.id);
|
||||
const roleResults = await db
|
||||
.select({
|
||||
id: snippets.id,
|
||||
name: snippets.name,
|
||||
content: snippets.content,
|
||||
description: snippets.description,
|
||||
folder: snippets.folder,
|
||||
ownerUsername: users.username,
|
||||
permissionLevel: snippetAccess.permissionLevel,
|
||||
expiresAt: snippetAccess.expiresAt,
|
||||
})
|
||||
.from(snippetAccess)
|
||||
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
|
||||
.innerJoin(users, eq(snippets.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
isNull(snippetAccess.expiresAt),
|
||||
gte(snippetAccess.expiresAt, now),
|
||||
),
|
||||
sql`${snippetAccess.roleId} IN (${sql.join(
|
||||
roleIds.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
),
|
||||
);
|
||||
|
||||
roleShared = roleResults.filter((s) => !directIds.includes(s.id));
|
||||
}
|
||||
|
||||
res.json({ sharedSnippets: [...directShared, ...roleShared] });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get shared snippets", error, {
|
||||
operation: "get_shared_snippets",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get shared snippets" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface SnippetReorderUpdate {
|
||||
id: number;
|
||||
order: number;
|
||||
folder?: string;
|
||||
}
|
||||
|
||||
type SnippetReorderRequestBody = {
|
||||
snippets?: unknown;
|
||||
updates?: unknown;
|
||||
};
|
||||
|
||||
export function extractSnippetReorderUpdates(
|
||||
body: unknown,
|
||||
): SnippetReorderUpdate[] | null {
|
||||
if (!body || typeof body !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = body as SnippetReorderRequestBody;
|
||||
// Keep accepting the legacy `updates` key so older clients do not break
|
||||
// while the web and desktop helpers converge on `snippets`.
|
||||
const snippetsUpdates = Array.isArray(payload.snippets)
|
||||
? payload.snippets
|
||||
: Array.isArray(payload.updates)
|
||||
? payload.updates
|
||||
: null;
|
||||
|
||||
if (!snippetsUpdates) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return snippetsUpdates as SnippetReorderUpdate[];
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { eq, and, desc, asc, sql } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import { authLogger, databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
|
||||
import { extractSnippetReorderUpdates } from "./snippets-reorder.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -472,7 +474,8 @@ router.delete(
|
||||
* /snippets/reorder:
|
||||
* put:
|
||||
* summary: Reorder snippets
|
||||
* description: Bulk updates the order and folder of snippets.
|
||||
* description: Bulk updates the order and folder of snippets. Accepts
|
||||
* `snippets` and the legacy `updates` payload key.
|
||||
* tags:
|
||||
* - Snippets
|
||||
* requestBody:
|
||||
@@ -507,14 +510,14 @@ router.put(
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { snippets: snippetUpdates } = req.body;
|
||||
const snippetUpdates = extractSnippetReorderUpdates(req.body);
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
authLogger.warn("Invalid userId for snippet reorder");
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
|
||||
if (!Array.isArray(snippetUpdates) || snippetUpdates.length === 0) {
|
||||
if (!snippetUpdates || snippetUpdates.length === 0) {
|
||||
authLogger.warn("Invalid snippet reorder data", {
|
||||
operation: "snippet_reorder",
|
||||
userId,
|
||||
@@ -775,18 +778,7 @@ router.post(
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: [
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
"aes256-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
cipher: SSH_ALGORITHMS.cipher,
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
|
||||
@@ -62,11 +62,37 @@ router.post(
|
||||
return res.status(400).json({ error: "Missing required parameters" });
|
||||
}
|
||||
|
||||
const sensitivePatterns = [
|
||||
/passw(or)?d/i,
|
||||
/\bsecret\b/i,
|
||||
/\btoken\b/i,
|
||||
/\bapi.?key\b/i,
|
||||
/PASS(WORD)?=/i,
|
||||
/AWS_SECRET/i,
|
||||
/mysql\b.*-p/i,
|
||||
/sudo\s+-S\b/,
|
||||
/htpasswd/i,
|
||||
/sshpass/i,
|
||||
/curl\b.*-u\s/i,
|
||||
/export\b.*(?:PASSWORD|SECRET|TOKEN|KEY)=/i,
|
||||
];
|
||||
|
||||
const trimmedCommand = command.trim();
|
||||
if (sensitivePatterns.some((p: RegExp) => p.test(trimmedCommand))) {
|
||||
return res.status(201).json({
|
||||
id: 0,
|
||||
userId,
|
||||
hostId: parseInt(hostId, 10),
|
||||
command: trimmedCommand,
|
||||
executedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const insertData = {
|
||||
userId,
|
||||
hostId: parseInt(hostId, 10),
|
||||
command: command.trim(),
|
||||
command: trimmedCommand,
|
||||
};
|
||||
|
||||
const result = await db
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import { restartGuacServer } from "../../guacamole/guacamole-server.js";
|
||||
import { setGlobalLogLevel, getGlobalLogLevel } from "../../utils/logger.js";
|
||||
import crypto from "crypto";
|
||||
import { db } from "../db/index.js";
|
||||
import {
|
||||
users,
|
||||
sessions,
|
||||
trustedDevices,
|
||||
hosts,
|
||||
sshCredentials,
|
||||
fileManagerRecent,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
networkTopology,
|
||||
dashboardPreferences,
|
||||
opksshTokens,
|
||||
apiKeys,
|
||||
} from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
@@ -734,7 +737,8 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => {
|
||||
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
|
||||
.get();
|
||||
if (!row) {
|
||||
return res.json(null);
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
return res.json(envConfig);
|
||||
}
|
||||
|
||||
let config = JSON.parse((row as Record<string, unknown>).value as string);
|
||||
@@ -1139,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 {
|
||||
const regRow = db.$client
|
||||
.prepare(
|
||||
@@ -1226,7 +1234,7 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
const sessionDurationMs =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 2 * 60 * 60 * 1000;
|
||||
: 24 * 60 * 60 * 1000;
|
||||
await authManager.registerOIDCUser(id, sessionDurationMs);
|
||||
} catch (encryptionError) {
|
||||
await db.delete(users).where(eq(users.id, id));
|
||||
@@ -1323,7 +1331,7 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: storedRememberMe
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 2 * 60 * 60 * 1000;
|
||||
: 24 * 60 * 60 * 1000;
|
||||
|
||||
res.clearCookie("jwt", authManager.getClearCookieOptions(req));
|
||||
|
||||
@@ -1569,15 +1577,13 @@ router.post("/login", async (req, res) => {
|
||||
username: userRecord.username,
|
||||
};
|
||||
|
||||
const isElectron =
|
||||
req.headers["x-electron-app"] === "true" ||
|
||||
req.headers["X-Electron-App"] === "true";
|
||||
|
||||
if (isElectron) {
|
||||
response.token = token;
|
||||
}
|
||||
|
||||
const maxAge = rememberMe ? 30 * 24 * 60 * 60 * 1000 : 2 * 60 * 60 * 1000;
|
||||
const timeoutRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
|
||||
.get() as { value: string } | undefined;
|
||||
const timeoutHours = timeoutRow ? parseInt(timeoutRow.value, 10) || 24 : 24;
|
||||
const maxAge = rememberMe
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: timeoutHours * 60 * 60 * 1000;
|
||||
|
||||
return res
|
||||
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
|
||||
@@ -1608,18 +1614,7 @@ router.post("/logout", authenticateJWT, async (req, res) => {
|
||||
const userId = authReq.userId;
|
||||
|
||||
if (userId) {
|
||||
const token =
|
||||
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
|
||||
}
|
||||
}
|
||||
const sessionId = authReq.sessionId;
|
||||
|
||||
await authManager.logoutUser(userId, sessionId);
|
||||
authLogger.info("User logged out", {
|
||||
@@ -1680,6 +1675,7 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
||||
is_oidc: !!user[0].isOidc,
|
||||
is_dual_auth: isDualAuth,
|
||||
totp_enabled: !!user[0].totpEnabled,
|
||||
data_unlocked: authManager.isUserUnlocked(userId),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get username", err);
|
||||
@@ -2155,12 +2151,16 @@ router.post("/initiate-reset", async (req, res) => {
|
||||
authLogger.warn(
|
||||
`Password reset attempted for non-existent user: ${username}`,
|
||||
);
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
return res.json({
|
||||
message:
|
||||
"If the user exists, a password reset code has been generated. Check docker logs for the code.",
|
||||
});
|
||||
}
|
||||
|
||||
if (user[0].isOidc) {
|
||||
return res.status(403).json({
|
||||
error: "Password reset not available for external authentication users",
|
||||
return res.json({
|
||||
message:
|
||||
"If the user exists, a password reset code has been generated. Check docker logs for the code.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2175,7 +2175,7 @@ router.post("/initiate-reset", async (req, res) => {
|
||||
);
|
||||
|
||||
authLogger.info(
|
||||
`Password reset code for user ${username}: ${resetCode} (expires at ${expiresAt.toLocaleString()})`,
|
||||
`Password reset code generated for user ${username} (expires at ${expiresAt.toLocaleString()}). Check admin panel or database settings table for code.`,
|
||||
);
|
||||
|
||||
res.json({
|
||||
@@ -2640,13 +2640,7 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
|
||||
* description: Failed to list users.
|
||||
*/
|
||||
router.get("/list", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0 || !user[0].isAdmin) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
const allUsers = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
@@ -2679,13 +2673,17 @@ router.get("/list", authenticateJWT, async (req, res) => {
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* userId:
|
||||
* type: string
|
||||
* description: Preferred unique user identifier.
|
||||
* username:
|
||||
* type: string
|
||||
* description: Legacy fallback identifier.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: User is now an admin.
|
||||
* 400:
|
||||
* description: Username is required or user is already an admin.
|
||||
* description: User ID or username is required, or the user is already an admin.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 404:
|
||||
@@ -2695,10 +2693,14 @@ router.get("/list", authenticateJWT, async (req, res) => {
|
||||
*/
|
||||
router.post("/make-admin", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { username } = req.body;
|
||||
const { userId: targetUserId, username } = req.body;
|
||||
const resolvedUserId = isNonEmptyString(targetUserId)
|
||||
? targetUserId.trim()
|
||||
: null;
|
||||
const resolvedUsername = isNonEmptyString(username) ? username.trim() : null;
|
||||
|
||||
if (!isNonEmptyString(username)) {
|
||||
return res.status(400).json({ error: "Username is required" });
|
||||
if (!resolvedUserId && !resolvedUsername) {
|
||||
return res.status(400).json({ error: "User ID or username is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -2710,7 +2712,12 @@ router.post("/make-admin", authenticateJWT, async (req, res) => {
|
||||
const targetUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username));
|
||||
.where(
|
||||
resolvedUserId
|
||||
? eq(users.id, resolvedUserId)
|
||||
: eq(users.username, resolvedUsername!),
|
||||
)
|
||||
.limit(1);
|
||||
if (!targetUser || targetUser.length === 0) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
@@ -2722,7 +2729,11 @@ router.post("/make-admin", authenticateJWT, async (req, res) => {
|
||||
await db
|
||||
.update(users)
|
||||
.set({ isAdmin: true })
|
||||
.where(eq(users.username, username));
|
||||
.where(
|
||||
resolvedUserId
|
||||
? eq(users.id, resolvedUserId)
|
||||
: eq(users.username, resolvedUsername!),
|
||||
);
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
@@ -2730,7 +2741,8 @@ router.post("/make-admin", authenticateJWT, async (req, res) => {
|
||||
} catch (saveError) {
|
||||
authLogger.error("Failed to persist admin promotion to disk", saveError, {
|
||||
operation: "make_admin_save_failed",
|
||||
username,
|
||||
userId: targetUser[0].id,
|
||||
username: targetUser[0].username,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2738,9 +2750,9 @@ router.post("/make-admin", authenticateJWT, async (req, res) => {
|
||||
operation: "admin_grant",
|
||||
adminId: userId,
|
||||
targetUserId: targetUser[0].id,
|
||||
targetUsername: username,
|
||||
targetUsername: targetUser[0].username,
|
||||
});
|
||||
res.json({ message: `User ${username} is now an admin` });
|
||||
res.json({ message: `User ${targetUser[0].username} is now an admin` });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to make user admin", err);
|
||||
res.status(500).json({ error: "Failed to make user admin" });
|
||||
@@ -2762,13 +2774,17 @@ router.post("/make-admin", authenticateJWT, async (req, res) => {
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* userId:
|
||||
* type: string
|
||||
* description: Preferred unique user identifier.
|
||||
* username:
|
||||
* type: string
|
||||
* description: Legacy fallback identifier.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Admin status removed from user.
|
||||
* 400:
|
||||
* description: Username is required or cannot remove your own admin status.
|
||||
* description: User ID or username is required, or cannot remove your own admin status.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 404:
|
||||
@@ -2778,10 +2794,14 @@ router.post("/make-admin", authenticateJWT, async (req, res) => {
|
||||
*/
|
||||
router.post("/remove-admin", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { username } = req.body;
|
||||
const { userId: targetUserId, username } = req.body;
|
||||
const resolvedUserId = isNonEmptyString(targetUserId)
|
||||
? targetUserId.trim()
|
||||
: null;
|
||||
const resolvedUsername = isNonEmptyString(username) ? username.trim() : null;
|
||||
|
||||
if (!isNonEmptyString(username)) {
|
||||
return res.status(400).json({ error: "Username is required" });
|
||||
if (!resolvedUserId && !resolvedUsername) {
|
||||
return res.status(400).json({ error: "User ID or username is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -2790,7 +2810,10 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
if (adminUser[0].username === username) {
|
||||
if (
|
||||
(resolvedUserId && adminUser[0].id === resolvedUserId) ||
|
||||
(resolvedUsername && adminUser[0].username === resolvedUsername)
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Cannot remove your own admin status" });
|
||||
@@ -2799,7 +2822,12 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => {
|
||||
const targetUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username));
|
||||
.where(
|
||||
resolvedUserId
|
||||
? eq(users.id, resolvedUserId)
|
||||
: eq(users.username, resolvedUsername!),
|
||||
)
|
||||
.limit(1);
|
||||
if (!targetUser || targetUser.length === 0) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
@@ -2811,7 +2839,11 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => {
|
||||
await db
|
||||
.update(users)
|
||||
.set({ isAdmin: false })
|
||||
.where(eq(users.username, username));
|
||||
.where(
|
||||
resolvedUserId
|
||||
? eq(users.id, resolvedUserId)
|
||||
: eq(users.username, resolvedUsername!),
|
||||
);
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
@@ -2819,7 +2851,8 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => {
|
||||
} catch (saveError) {
|
||||
authLogger.error("Failed to persist admin removal to disk", saveError, {
|
||||
operation: "remove_admin_save_failed",
|
||||
username,
|
||||
userId: targetUser[0].id,
|
||||
username: targetUser[0].username,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2827,9 +2860,11 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => {
|
||||
operation: "admin_revoke",
|
||||
adminId: userId,
|
||||
targetUserId: targetUser[0].id,
|
||||
targetUsername: username,
|
||||
targetUsername: targetUser[0].username,
|
||||
});
|
||||
res.json({
|
||||
message: `Admin status removed from ${targetUser[0].username}`,
|
||||
});
|
||||
res.json({ message: `Admin status removed from ${username}` });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to remove admin status", err);
|
||||
res.status(500).json({ error: "Failed to remove admin status" });
|
||||
@@ -2966,10 +3001,19 @@ router.post("/totp/enable", authenticateJWT, async (req, res) => {
|
||||
totpBackupCodes: JSON.stringify(backupCodes),
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
authLogger.info("Two-factor authentication enabled", {
|
||||
operation: "totp_enable",
|
||||
userId,
|
||||
});
|
||||
|
||||
await db.delete(sessions).where(eq(sessions.userId, userId));
|
||||
await db.delete(trustedDevices).where(eq(trustedDevices.userId, userId));
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
authLogger.error("Failed to persist TOTP enablement to disk", saveError, {
|
||||
operation: "totp_enable_db_save_failed",
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: "TOTP enabled successfully",
|
||||
@@ -3335,10 +3379,6 @@ router.post("/totp/verify-login", async (req, res) => {
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
});
|
||||
|
||||
const isElectron =
|
||||
req.headers["x-electron-app"] === "true" ||
|
||||
req.headers["X-Electron-App"] === "true";
|
||||
|
||||
authLogger.success("TOTP verification successful", {
|
||||
operation: "totp_verify_success",
|
||||
userId: userRecord.id,
|
||||
@@ -3355,11 +3395,13 @@ router.post("/totp/verify-login", async (req, res) => {
|
||||
totp_enabled: !!userRecord.totpEnabled,
|
||||
};
|
||||
|
||||
if (isElectron) {
|
||||
response.token = token;
|
||||
}
|
||||
|
||||
const maxAge = rememberMe ? 30 * 24 * 60 * 60 * 1000 : 2 * 60 * 60 * 1000;
|
||||
const timeoutRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
|
||||
.get() as { value: string } | undefined;
|
||||
const timeoutHours = timeoutRow ? parseInt(timeoutRow.value, 10) || 24 : 24;
|
||||
const maxAge = rememberMe
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: timeoutHours * 60 * 60 * 1000;
|
||||
|
||||
return res
|
||||
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
|
||||
@@ -3493,9 +3535,14 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
|
||||
* description: Failed to unlock data.
|
||||
*/
|
||||
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;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
return res.status(400).json({ error: "Password is required" });
|
||||
}
|
||||
@@ -3503,6 +3550,19 @@ router.post("/unlock-data", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const unlocked = await authManager.authenticateUser(userId, password);
|
||||
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({
|
||||
success: true,
|
||||
message: "Data unlocked successfully",
|
||||
@@ -3541,9 +3601,10 @@ router.get("/data-status", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
try {
|
||||
const unlocked = authManager.isUserUnlocked(userId);
|
||||
res.json({
|
||||
unlocked: true,
|
||||
message: "Data is unlocked",
|
||||
unlocked,
|
||||
message: unlocked ? "Data is unlocked" : "Data is locked",
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to check data status", err, {
|
||||
@@ -3571,7 +3632,9 @@ router.get("/data-status", authenticateJWT, async (req, res) => {
|
||||
* description: Failed to get sessions.
|
||||
*/
|
||||
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 {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
@@ -3594,8 +3657,16 @@ router.get("/sessions", authenticateJWT, async (req, res) => {
|
||||
.limit(1);
|
||||
|
||||
return {
|
||||
...session,
|
||||
id: session.id,
|
||||
userId: session.userId,
|
||||
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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -3603,7 +3674,19 @@ router.get("/sessions", authenticateJWT, async (req, res) => {
|
||||
return res.json({ sessions: enrichedSessions });
|
||||
} else {
|
||||
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) {
|
||||
authLogger.error("Failed to get sessions", err);
|
||||
@@ -3745,12 +3828,7 @@ router.post("/sessions/revoke-all", authenticateJWT, async (req, res) => {
|
||||
|
||||
let currentSessionId: string | undefined;
|
||||
if (exceptCurrent) {
|
||||
const token =
|
||||
req.cookies?.jwt || req.headers?.authorization?.split(" ")[1];
|
||||
if (token) {
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
currentSessionId = payload?.sessionId;
|
||||
}
|
||||
currentSessionId = (req as AuthenticatedRequest).sessionId;
|
||||
}
|
||||
|
||||
const revokedCount = await authManager.revokeAllUserSessions(
|
||||
@@ -4138,7 +4216,7 @@ router.post("/unlink-oidc-from-password", authenticateJWT, async (req, res) => {
|
||||
* 500:
|
||||
* description: Failed to get guacamole settings.
|
||||
*/
|
||||
router.get("/guacamole-settings", async (req, res) => {
|
||||
router.get("/guacamole-settings", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const enabledRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
|
||||
@@ -4226,4 +4304,349 @@ router.patch("/guacamole-settings", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/log-level:
|
||||
* get:
|
||||
* summary: Get log level setting
|
||||
* description: Returns the configured log verbosity level.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Current log level.
|
||||
*/
|
||||
router.get("/log-level", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
const row = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'log_level'")
|
||||
.get() as { value: string } | undefined;
|
||||
res.json({
|
||||
level: row ? row.value : getGlobalLogLevel(),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get log level", err);
|
||||
res.status(500).json({ error: "Failed to get log level" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/log-level:
|
||||
* patch:
|
||||
* summary: Update log level setting (admin only)
|
||||
* description: Sets the log verbosity level.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Log level updated.
|
||||
* 400:
|
||||
* description: Invalid log level.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
*/
|
||||
router.patch("/log-level", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0 || !user[0].isAdmin) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
const { level } = req.body;
|
||||
const validLevels = ["debug", "info", "warn", "error"];
|
||||
if (typeof level !== "string" || !validLevels.includes(level)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "level must be one of: debug, info, warn, error" });
|
||||
}
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('log_level', ?)",
|
||||
)
|
||||
.run(level);
|
||||
setGlobalLogLevel(level);
|
||||
res.json({ level });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to set log level", err);
|
||||
res.status(500).json({ error: "Failed to set log level" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/session-timeout:
|
||||
* get:
|
||||
* summary: Get session timeout setting
|
||||
* description: Returns the configured session timeout in hours.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Current session timeout hours.
|
||||
*/
|
||||
router.get("/session-timeout", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
const row = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
|
||||
.get() as { value: string } | undefined;
|
||||
res.json({
|
||||
timeoutHours: row ? parseInt(row.value, 10) : 24,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get session timeout", err);
|
||||
res.status(500).json({ error: "Failed to get session timeout" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/session-timeout:
|
||||
* patch:
|
||||
* summary: Update session timeout setting (admin only)
|
||||
* description: Sets the session timeout in hours.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session timeout updated.
|
||||
* 400:
|
||||
* description: Invalid value.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
*/
|
||||
router.patch("/session-timeout", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0 || !user[0].isAdmin) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
const { timeoutHours } = req.body;
|
||||
if (
|
||||
typeof timeoutHours !== "number" ||
|
||||
timeoutHours < 1 ||
|
||||
timeoutHours > 720
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "timeoutHours must be between 1 and 720" });
|
||||
}
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('session_timeout_hours', ?)",
|
||||
)
|
||||
.run(String(timeoutHours));
|
||||
res.json({ timeoutHours });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to set session timeout", err);
|
||||
res.status(500).json({ error: "Failed to set session timeout" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @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;
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import GuacamoleLite from "guacamole-lite";
|
||||
import { parse as parseUrl } from "url";
|
||||
import { guacLogger } from "../utils/logger.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { GuacamoleTokenService } from "./token-service.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import type { IncomingMessage } from "http";
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
|
||||
function parseGuacUrl(url: string): { host: string; port: number } {
|
||||
@@ -58,7 +54,7 @@ const clientOptions = {
|
||||
},
|
||||
allowedUnencryptedConnectionSettings: {
|
||||
rdp: ["width", "height", "dpi"],
|
||||
vnc: ["width", "height", "dpi"],
|
||||
vnc: ["width", "height"],
|
||||
telnet: ["width", "height"],
|
||||
},
|
||||
connectionDefaultSettings: {
|
||||
@@ -74,6 +70,7 @@ const clientOptions = {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
dpi: 96,
|
||||
audio: ["audio/L16"],
|
||||
},
|
||||
vnc: {
|
||||
"swap-red-blue": false,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PermissionManager } from "../utils/permission-manager.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { getDb } from "../database/db/index.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";
|
||||
|
||||
const router = express.Router();
|
||||
@@ -31,7 +31,6 @@ router.use(authManager.createAuthMiddleware());
|
||||
*/
|
||||
router.post("/token", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { type, hostname, port, username, password, domain, ...options } =
|
||||
req.body;
|
||||
|
||||
@@ -63,10 +62,15 @@ router.post("/token", async (req, res) => {
|
||||
);
|
||||
break;
|
||||
case "vnc":
|
||||
token = tokenService.createVncToken(hostname, password, {
|
||||
port: port || 5900,
|
||||
...options,
|
||||
});
|
||||
token = tokenService.createVncToken(
|
||||
hostname,
|
||||
username || undefined,
|
||||
password,
|
||||
{
|
||||
port: port || 5900,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
break;
|
||||
case "telnet":
|
||||
token = tokenService.createTelnetToken(hostname, username, password, {
|
||||
@@ -129,7 +133,7 @@ router.post(
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
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)) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
@@ -206,10 +210,15 @@ router.post(
|
||||
});
|
||||
break;
|
||||
case "vnc":
|
||||
token = tokenService.createVncToken(hostname, password, {
|
||||
port: port || 5900,
|
||||
...guacConfig,
|
||||
});
|
||||
token = tokenService.createVncToken(
|
||||
hostname,
|
||||
username || undefined,
|
||||
password,
|
||||
{
|
||||
port: port || 5900,
|
||||
...guacConfig,
|
||||
},
|
||||
);
|
||||
break;
|
||||
case "telnet":
|
||||
token = tokenService.createTelnetToken(hostname, username, password, {
|
||||
|
||||
@@ -141,6 +141,7 @@ export class GuacamoleTokenService {
|
||||
|
||||
createVncToken(
|
||||
hostname: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> = {},
|
||||
): string {
|
||||
@@ -149,6 +150,7 @@ export class GuacamoleTokenService {
|
||||
type: "vnc",
|
||||
settings: {
|
||||
hostname,
|
||||
...(username ? { username } : {}),
|
||||
password,
|
||||
port: 5900,
|
||||
...options,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { parse as parseUrl } from "url";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
@@ -27,8 +26,20 @@ const wss = new WebSocketServer({
|
||||
port: 30009,
|
||||
verifyClient: async (info) => {
|
||||
try {
|
||||
const url = parseUrl(info.req.url || "", true);
|
||||
const token = url.query.token as string;
|
||||
let token: string | undefined;
|
||||
|
||||
const cookieHeader = info.req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
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) {
|
||||
return false;
|
||||
@@ -75,8 +86,10 @@ async function detectShell(
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", () => {
|
||||
// Ignore stderr
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
stream.on("error", (streamErr) => {
|
||||
reject(streamErr);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -229,7 +242,34 @@ async function createJumpHostChain(
|
||||
}
|
||||
|
||||
wss.on("connection", async (ws: WebSocket, req) => {
|
||||
const userId = (req as unknown as { userId: string }).userId;
|
||||
let token: string | undefined;
|
||||
|
||||
const cookieHeader = req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) token = decodeURIComponent(match[1]);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
token = authHeader.slice("Bearer ".length);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
const authManagerInstance = AuthManager.getInstance();
|
||||
const payload = await authManagerInstance.verifyJWTToken(token);
|
||||
if (!payload || !payload.userId) {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = payload.userId;
|
||||
const sessionId = `docker-console-${Date.now()}-${Math.random()}`;
|
||||
sshLogger.info("Docker console WebSocket connected", {
|
||||
operation: "docker_console_connect",
|
||||
@@ -260,21 +300,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
rows?: number;
|
||||
};
|
||||
|
||||
if (
|
||||
typeof hostConfig.jumpHosts === "string" &&
|
||||
hostConfig.jumpHosts
|
||||
) {
|
||||
try {
|
||||
hostConfig.jumpHosts = JSON.parse(hostConfig.jumpHosts);
|
||||
} catch (e) {
|
||||
sshLogger.error("Failed to parse jump hosts", e, {
|
||||
hostId: hostConfig.id,
|
||||
});
|
||||
hostConfig.jumpHosts = [];
|
||||
}
|
||||
}
|
||||
const hostId = hostConfig?.id;
|
||||
|
||||
if (!hostConfig || !containerId) {
|
||||
if (!hostId || !containerId) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
@@ -284,62 +312,69 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(containerId)) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Invalid container ID",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedShells = ["bash", "sh", "ash", "zsh"];
|
||||
if (shell && !allowedShells.includes(shell)) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Invalid shell",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hostConfig.enableDocker) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"Docker is not enabled for this host. Enable it in Host Settings.",
|
||||
message: "Docker is not enabled on this host",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let resolvedCredentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
authType?: string;
|
||||
} = {
|
||||
password: hostConfig.password,
|
||||
sshKey: hostConfig.key,
|
||||
keyPassword: hostConfig.keyPassword,
|
||||
authType: hostConfig.authType,
|
||||
};
|
||||
// Resolve host with credentials server-side
|
||||
const { resolveHostById } = await import("./host-resolver.js");
|
||||
const resolvedHost = await resolveHostById(hostId, userId);
|
||||
|
||||
if (hostConfig.credentialId) {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, hostConfig.credentialId as number),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
if (!resolvedHost) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Host not found",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedCredentials = {
|
||||
password: credential.password as string | undefined,
|
||||
sshKey: credential.privateKey as string | undefined,
|
||||
keyPassword: credential.keyPassword as string | undefined,
|
||||
authType: credential.authType as string | undefined,
|
||||
};
|
||||
}
|
||||
if (!resolvedHost.enableDocker) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"Docker is not enabled for this host. Enable it in Host Settings.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new SSHClient();
|
||||
|
||||
const config: Record<string, unknown> = {
|
||||
host: hostConfig.ip?.replace(/^\[|\]$/g, "") || hostConfig.ip,
|
||||
port: hostConfig.port || 22,
|
||||
username: hostConfig.username,
|
||||
host: resolvedHost.ip?.replace(/^\[|\]$/g, "") || resolvedHost.ip,
|
||||
port: resolvedHost.port || 22,
|
||||
username: resolvedHost.username,
|
||||
tryKeyboard: true,
|
||||
readyTimeout: 60000,
|
||||
keepaliveInterval: 30000,
|
||||
@@ -348,28 +383,22 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
};
|
||||
|
||||
if (
|
||||
resolvedCredentials.authType === "password" &&
|
||||
resolvedCredentials.password
|
||||
) {
|
||||
config.password = resolvedCredentials.password;
|
||||
} else if (
|
||||
resolvedCredentials.authType === "key" &&
|
||||
resolvedCredentials.sshKey
|
||||
) {
|
||||
const cleanKey = resolvedCredentials.sshKey
|
||||
if (resolvedHost.authType === "password" && resolvedHost.password) {
|
||||
config.password = resolvedHost.password;
|
||||
} else if (resolvedHost.authType === "key" && resolvedHost.key) {
|
||||
const cleanKey = resolvedHost.key
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
config.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (resolvedCredentials.keyPassword) {
|
||||
config.passphrase = resolvedCredentials.keyPassword;
|
||||
if (resolvedHost.keyPassword) {
|
||||
config.passphrase = resolvedHost.keyPassword;
|
||||
}
|
||||
}
|
||||
|
||||
if (hostConfig.jumpHosts && hostConfig.jumpHosts.length > 0) {
|
||||
if (resolvedHost.jumpHosts && resolvedHost.jumpHosts.length > 0) {
|
||||
const jumpClient = await createJumpHostChain(
|
||||
hostConfig.jumpHosts,
|
||||
resolvedHost.jumpHosts,
|
||||
userId,
|
||||
);
|
||||
if (jumpClient) {
|
||||
@@ -378,8 +407,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
jumpClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
hostConfig.ip,
|
||||
hostConfig.port || 22,
|
||||
resolvedHost.ip,
|
||||
resolvedHost.port || 22,
|
||||
(err, stream) => {
|
||||
if (err) return reject(err);
|
||||
resolve(stream);
|
||||
@@ -402,7 +431,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
stream: null,
|
||||
isConnected: true,
|
||||
containerId,
|
||||
hostId: hostConfig.id,
|
||||
hostId: resolvedHost.id,
|
||||
};
|
||||
|
||||
activeSessions.set(sessionId, sshSession);
|
||||
@@ -430,8 +459,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", () => {
|
||||
// Ignore stderr
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
stream.on("error", (streamErr) => {
|
||||
reject(streamErr);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -459,7 +490,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
operation: "docker_attach",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId: hostConfig.id,
|
||||
hostId: resolvedHost.id,
|
||||
containerId,
|
||||
});
|
||||
|
||||
@@ -494,7 +525,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
operation: "docker_attach_success",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId: hostConfig.id,
|
||||
hostId: resolvedHost.id,
|
||||
containerId,
|
||||
});
|
||||
|
||||
@@ -509,8 +540,23 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", () => {
|
||||
// stderr output ignored
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
sshLogger.error("Docker console stream error", streamErr, {
|
||||
operation: "docker_console_stream_error",
|
||||
sessionId,
|
||||
containerId,
|
||||
});
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: `Console error: ${streamErr.message}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
|
||||
+33
-76
@@ -1,5 +1,5 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import cookieParser from "cookie-parser";
|
||||
import axios from "axios";
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
@@ -9,6 +9,7 @@ import { eq, and } from "drizzle-orm";
|
||||
import { logger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import {
|
||||
createSocks5Connection,
|
||||
type SOCKS5Config,
|
||||
@@ -40,6 +41,7 @@ interface SSHSession {
|
||||
timeout?: NodeJS.Timeout;
|
||||
activeOperations: number;
|
||||
hostId?: number;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
interface PendingTOTPSession {
|
||||
@@ -424,39 +426,7 @@ async function executeDockerCommand(
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("https://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("http://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
const allowedOrigins = ["http://localhost:5173", "http://127.0.0.1:5173"];
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
return callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"X-Electron-App",
|
||||
],
|
||||
}),
|
||||
);
|
||||
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
|
||||
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: "100mb" }));
|
||||
@@ -469,6 +439,16 @@ app.use((_req, res, next) => {
|
||||
const authManager = AuthManager.getInstance();
|
||||
app.use(authManager.createAuthMiddleware());
|
||||
|
||||
const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
||||
const DOCKER_TIMESTAMP_RE = /^[0-9T:.Z+-]+$/;
|
||||
|
||||
app.param("containerId", (req, res, next, value) => {
|
||||
if (!CONTAINER_ID_RE.test(value)) {
|
||||
return res.status(400).json({ error: "Invalid container ID" });
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /docker/ssh/connect:
|
||||
@@ -791,18 +771,13 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const { promises: fs } = await import("fs");
|
||||
const path = await import("path");
|
||||
const os = await import("os");
|
||||
|
||||
const tempDir = os.tmpdir();
|
||||
const keyPath = path.join(tempDir, `opkssh-docker-${userId}-${hostId}`);
|
||||
const certPath = `${keyPath}-cert.pub`;
|
||||
|
||||
await fs.writeFile(keyPath, token.privateKey, { mode: 0o600 });
|
||||
await fs.writeFile(certPath, token.sshCert, { mode: 0o600 });
|
||||
|
||||
config.privateKey = await fs.readFile(keyPath);
|
||||
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
|
||||
await setupOPKSSHCertAuth(
|
||||
config as import("ssh2").ConnectConfig,
|
||||
client,
|
||||
token,
|
||||
host.username,
|
||||
);
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"info",
|
||||
@@ -810,32 +785,6 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
"Using OPKSSH certificate authentication",
|
||||
),
|
||||
);
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const cleanupResults = await Promise.allSettled([
|
||||
fs.unlink(keyPath),
|
||||
fs.unlink(certPath),
|
||||
]);
|
||||
|
||||
cleanupResults.forEach((result, index) => {
|
||||
if (result.status === "rejected") {
|
||||
sshLogger.warn(`Failed to cleanup OPKSSH temp file`, {
|
||||
operation: "opkssh_temp_cleanup_failed",
|
||||
file: index === 0 ? "keyPath" : "certPath",
|
||||
sessionId,
|
||||
error: result.reason,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to cleanup OPKSSH temp files", {
|
||||
operation: "opkssh_temp_cleanup_error",
|
||||
sessionId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}, 60000);
|
||||
} catch (opksshError) {
|
||||
sshLogger.error("OPKSSH authentication error for Docker", {
|
||||
operation: "docker_connect",
|
||||
@@ -983,6 +932,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0,
|
||||
hostId,
|
||||
userId,
|
||||
};
|
||||
|
||||
scheduleSessionCleanup(sessionId);
|
||||
@@ -1021,7 +971,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
userId,
|
||||
});
|
||||
|
||||
let errorStage: ConnectionStage = "error";
|
||||
let errorStage: ConnectionStage;
|
||||
if (
|
||||
err.message.includes("ENOTFOUND") ||
|
||||
err.message.includes("getaddrinfo")
|
||||
@@ -1665,6 +1615,7 @@ app.post("/docker/ssh/connect-totp", async (req, res) => {
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0,
|
||||
hostId: session.hostId,
|
||||
userId,
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
@@ -1850,6 +1801,7 @@ app.post("/docker/ssh/connect-warpgate", async (req, res) => {
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0,
|
||||
hostId: session.hostId,
|
||||
userId,
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
@@ -1953,6 +1905,7 @@ app.post("/docker/ssh/connect-warpgate", async (req, res) => {
|
||||
*/
|
||||
app.post("/docker/ssh/keepalive", async (req, res) => {
|
||||
const { sessionId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
@@ -1967,6 +1920,10 @@ app.post("/docker/ssh/keepalive", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (session.userId && session.userId !== userId) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
session.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
@@ -3016,18 +2973,18 @@ app.get("/docker/containers/:sessionId/:containerId/logs", async (req, res) => {
|
||||
let command = `docker logs ${containerId}`;
|
||||
|
||||
if (tail && tail > 0) {
|
||||
command += ` --tail ${tail}`;
|
||||
command += ` --tail ${Math.floor(tail)}`;
|
||||
}
|
||||
|
||||
if (timestamps) {
|
||||
command += " --timestamps";
|
||||
}
|
||||
|
||||
if (since) {
|
||||
if (since && DOCKER_TIMESTAMP_RE.test(since)) {
|
||||
command += ` --since ${since}`;
|
||||
}
|
||||
|
||||
if (until) {
|
||||
if (until && DOCKER_TIMESTAMP_RE.test(until)) {
|
||||
command += ` --until ${until}`;
|
||||
}
|
||||
|
||||
|
||||
+613
-362
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,173 @@
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
import type { SSHHost } from "../../types/index.js";
|
||||
|
||||
const sshLogger = logger;
|
||||
|
||||
/**
|
||||
* Resolve a host with its credentials server-side by hostId.
|
||||
* This avoids passing credentials through the frontend.
|
||||
*/
|
||||
export async function resolveHostById(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<SSHHost | null> {
|
||||
const db = getDb();
|
||||
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
db.select().from(hosts).where(eq(hosts.id, hostId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hostResults.length === 0) return null;
|
||||
|
||||
const host = hostResults[0] as Record<string, unknown>;
|
||||
|
||||
// Parse JSON fields
|
||||
if (typeof host.jumpHosts === "string" && host.jumpHosts) {
|
||||
try {
|
||||
host.jumpHosts = JSON.parse(host.jumpHosts as string);
|
||||
} catch {
|
||||
host.jumpHosts = [];
|
||||
}
|
||||
}
|
||||
if (typeof host.tunnelConnections === "string") {
|
||||
try {
|
||||
host.tunnelConnections = JSON.parse(host.tunnelConnections as string);
|
||||
} catch {
|
||||
host.tunnelConnections = [];
|
||||
}
|
||||
}
|
||||
if (typeof host.statsConfig === "string" && host.statsConfig) {
|
||||
try {
|
||||
host.statsConfig = JSON.parse(host.statsConfig as string);
|
||||
} catch {
|
||||
host.statsConfig = undefined;
|
||||
}
|
||||
}
|
||||
if (typeof host.terminalConfig === "string" && host.terminalConfig) {
|
||||
try {
|
||||
host.terminalConfig = JSON.parse(host.terminalConfig as string);
|
||||
} catch {
|
||||
host.terminalConfig = undefined;
|
||||
}
|
||||
}
|
||||
if (typeof host.socks5ProxyChain === "string" && host.socks5ProxyChain) {
|
||||
try {
|
||||
host.socks5ProxyChain = JSON.parse(host.socks5ProxyChain as string);
|
||||
} catch {
|
||||
host.socks5ProxyChain = [];
|
||||
}
|
||||
}
|
||||
if (typeof host.quickActions === "string" && host.quickActions) {
|
||||
try {
|
||||
host.quickActions = JSON.parse(host.quickActions as string);
|
||||
} catch {
|
||||
host.quickActions = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve credential if using credential-based auth
|
||||
if (host.credentialId) {
|
||||
const ownerId = (host.userId || userId) as string;
|
||||
try {
|
||||
// Try shared credential first for non-owner users
|
||||
if (userId !== ownerId) {
|
||||
try {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
if (sharedCred) {
|
||||
host.password = sharedCred.password;
|
||||
host.key = sharedCred.key;
|
||||
host.keyPassword = sharedCred.keyPassword;
|
||||
host.keyType = sharedCred.keyType;
|
||||
if (!host.overrideCredentialUsername) {
|
||||
host.username = sharedCred.username;
|
||||
}
|
||||
host.authType = sharedCred.key
|
||||
? "key"
|
||||
: sharedCred.password
|
||||
? "password"
|
||||
: "none";
|
||||
return host as unknown as SSHHost;
|
||||
}
|
||||
} catch (e) {
|
||||
sshLogger.warn("Failed to get shared credential, falling back", {
|
||||
operation: "host_resolver_shared_credential",
|
||||
hostId,
|
||||
error: e instanceof Error ? e.message : "Unknown",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId as number),
|
||||
eq(sshCredentials.userId, ownerId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
ownerId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const cred = credentials[0] as Record<string, unknown>;
|
||||
host.password = cred.password;
|
||||
host.key = cred.key;
|
||||
host.keyPassword = cred.keyPassword;
|
||||
host.keyType = cred.keyType;
|
||||
if (!host.overrideCredentialUsername) {
|
||||
host.username = cred.username;
|
||||
}
|
||||
host.authType = cred.key ? "key" : cred.password ? "password" : "none";
|
||||
}
|
||||
} catch (e) {
|
||||
sshLogger.warn("Failed to resolve credential for host", {
|
||||
operation: "host_resolver_credential",
|
||||
hostId,
|
||||
error: e instanceof Error ? e.message : "Unknown",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return host as unknown as SSHHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has access to a host (owner or shared access).
|
||||
*/
|
||||
export async function checkHostAccess(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
hostUserId: string,
|
||||
requiredPermission: "read" | "execute" = "execute",
|
||||
): Promise<boolean> {
|
||||
if (userId === hostUserId) return true;
|
||||
|
||||
try {
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
requiredPermission,
|
||||
);
|
||||
return accessInfo.hasAccess;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+237
-37
@@ -1,7 +1,6 @@
|
||||
import { spawn, ChildProcess } from "child_process";
|
||||
import { randomUUID } from "crypto";
|
||||
import { WebSocket } from "ws";
|
||||
import { IncomingMessage } from "http";
|
||||
import { OPKSSHBinaryManager } from "../utils/opkssh-binary-manager.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
@@ -12,10 +11,12 @@ import { FieldCrypto } from "../utils/field-crypto.js";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import axios from "axios";
|
||||
import { getRequestOrigin } from "../utils/request-origin.js";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
const AUTH_TIMEOUT = 60 * 1000;
|
||||
|
||||
export const OPKSSH_CALLBACK_PATH = "/host/opkssh-callback";
|
||||
|
||||
interface OPKSSHAuthSession {
|
||||
requestId: string;
|
||||
userId: string;
|
||||
@@ -25,6 +26,7 @@ interface OPKSSHAuthSession {
|
||||
localPort: number;
|
||||
callbackPort: number;
|
||||
remoteRedirectUri: string;
|
||||
providers: Array<{ alias: string; issuer: string }>;
|
||||
status:
|
||||
| "starting"
|
||||
| "waiting_for_auth"
|
||||
@@ -47,6 +49,7 @@ interface OPKSSHAuthSession {
|
||||
}
|
||||
|
||||
const activeAuthSessions = new Map<string, OPKSSHAuthSession>();
|
||||
const oauthStateToRequestId = new Map<string, string>();
|
||||
const cleanupInProgress = new Set<string>();
|
||||
|
||||
function getOPKConfigPath(): string {
|
||||
@@ -78,10 +81,17 @@ async function createTemplateConfig(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
interface ProviderRedirectInfo {
|
||||
alias: string;
|
||||
issuer: string;
|
||||
redirectUris: string[];
|
||||
}
|
||||
|
||||
async function checkOPKConfigExists(): Promise<{
|
||||
exists: boolean;
|
||||
error?: string;
|
||||
configPath?: string;
|
||||
providers?: ProviderRedirectInfo[];
|
||||
}> {
|
||||
const configPath = getOPKConfigPath();
|
||||
const isDocker =
|
||||
@@ -119,15 +129,44 @@ async function checkOPKConfigExists(): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
if (!content.includes("redirect_uris:")) {
|
||||
return {
|
||||
exists: false,
|
||||
configPath,
|
||||
error: `OPKSSH configuration is missing 'redirect_uris' field. This field must contain the Termix callback URL that you registered with your OAuth provider (e.g., http://localhost:8080/host/opkssh-callback for Docker). The static callback route will internally redirect to the dynamic route for proper URL rewriting.`,
|
||||
let providers: ProviderRedirectInfo[] = [];
|
||||
try {
|
||||
const parsed = yaml.load(content) as {
|
||||
providers?: Array<{
|
||||
alias?: string;
|
||||
issuer?: string;
|
||||
redirect_uris?: string[];
|
||||
}>;
|
||||
};
|
||||
if (parsed?.providers && Array.isArray(parsed.providers)) {
|
||||
providers = parsed.providers
|
||||
.filter(
|
||||
(
|
||||
p,
|
||||
): p is {
|
||||
alias: string;
|
||||
issuer: string;
|
||||
redirect_uris?: string[];
|
||||
} => typeof p.alias === "string" && typeof p.issuer === "string",
|
||||
)
|
||||
.map((p) => ({
|
||||
alias: p.alias,
|
||||
issuer: p.issuer.replace(/^https?:\/\//, ""),
|
||||
redirectUris: Array.isArray(p.redirect_uris)
|
||||
? p.redirect_uris.filter(
|
||||
(u): u is string => typeof u === "string",
|
||||
)
|
||||
: [],
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
sshLogger.warn("Failed to parse OPKSSH config for providers", {
|
||||
operation: "opkssh_config_parse_providers_error",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
|
||||
return { exists: true, configPath };
|
||||
return { exists: true, configPath, providers };
|
||||
} catch {
|
||||
await createTemplateConfig();
|
||||
return {
|
||||
@@ -138,6 +177,63 @@ async function checkOPKConfigExists(): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// OPKSSH's `redirect_uris` field lists candidate LOCAL ports for the callback listener
|
||||
// that OPKSSH binds on the host running the binary. The openpubkey library enforces these
|
||||
// must be localhost, a non-localhost entry causes ECONNRESET on /select/ at runtime.
|
||||
// The publicly registered OAuth redirect URI is what Termix passes via --remote-redirect-uri
|
||||
// (derived from request origin); users do NOT put that URL in this config field.
|
||||
function validateRedirectUrisAreLocalhost(
|
||||
providers: ProviderRedirectInfo[],
|
||||
): { ok: true } | { ok: false; message: string } {
|
||||
const isLocalHost = (host: string): boolean => {
|
||||
const bare = host.replace(/^\[|\]$/g, "");
|
||||
return (
|
||||
bare === "localhost" ||
|
||||
bare === "127.0.0.1" ||
|
||||
bare === "::1" ||
|
||||
bare === "0:0:0:0:0:0:0:1" ||
|
||||
bare.startsWith("localhost:") ||
|
||||
bare.startsWith("127.0.0.1:")
|
||||
);
|
||||
};
|
||||
|
||||
const issues: string[] = [];
|
||||
for (const p of providers) {
|
||||
const uris = p.redirectUris || [];
|
||||
if (uris.length === 0) continue;
|
||||
const nonLocal = uris.filter((u) => {
|
||||
try {
|
||||
return !isLocalHost(new URL(u).hostname);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
if (nonLocal.length > 0) {
|
||||
issues.push(
|
||||
`Provider '${p.alias}': non-localhost entries in redirect_uris: ${nonLocal.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
`OPKSSH configuration error: 'redirect_uris' must only contain localhost URLs.\n\n` +
|
||||
`${issues.join("\n")}\n\n` +
|
||||
`This field is OPKSSH's local callback listener, it must be localhost (or omitted to use ` +
|
||||
`the defaults http://localhost:3000/login-callback, :10001, :11110). ` +
|
||||
`The public Termix callback URL is supplied automatically by Termix via --remote-redirect-uri; ` +
|
||||
`you do not put it here. Register the PUBLIC Termix URL with your OAuth provider instead ` +
|
||||
`(e.g. https://your-domain${OPKSSH_CALLBACK_PATH}).\n\n` +
|
||||
`Fix: remove the non-localhost entries above, or delete the whole 'redirect_uris' block to use defaults.\n\n` +
|
||||
`Docs: https://docs.termix.site/opkssh`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function startOPKSSHAuth(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
@@ -178,8 +274,37 @@ export async function startOPKSSHAuth(
|
||||
return "";
|
||||
}
|
||||
|
||||
const redirectValidation = validateRedirectUrisAreLocalhost(
|
||||
configCheck.providers || [],
|
||||
);
|
||||
if (redirectValidation.ok === false) {
|
||||
sshLogger.warn("OPKSSH config redirect_uris validation failed", {
|
||||
operation: "opkssh_config_redirect_uris_not_localhost",
|
||||
configPath: configCheck.configPath,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_config_error",
|
||||
requestId: "",
|
||||
error: redirectValidation.message,
|
||||
instructions: redirectValidation.message,
|
||||
}),
|
||||
);
|
||||
return "";
|
||||
}
|
||||
|
||||
const requestId = randomUUID();
|
||||
const remoteRedirectUri = `${requestOrigin}/host/opkssh-callback`;
|
||||
const remoteRedirectUri = `${requestOrigin}${OPKSSH_CALLBACK_PATH}`;
|
||||
|
||||
sshLogger.info("Starting OPKSSH auth session", {
|
||||
operation: "opkssh_start_auth_remote_redirect_uri",
|
||||
requestId,
|
||||
userId,
|
||||
hostId,
|
||||
requestOrigin,
|
||||
remoteRedirectUri,
|
||||
providerAliases: (configCheck.providers || []).map((p) => p.alias),
|
||||
});
|
||||
|
||||
const session: Partial<OPKSSHAuthSession> = {
|
||||
requestId,
|
||||
@@ -189,6 +314,7 @@ export async function startOPKSSHAuth(
|
||||
localPort: 0,
|
||||
callbackPort: 0,
|
||||
remoteRedirectUri,
|
||||
providers: configCheck.providers || [],
|
||||
status: "starting",
|
||||
ws,
|
||||
stdoutBuffer: "",
|
||||
@@ -261,7 +387,80 @@ export async function startOPKSSHAuth(
|
||||
handleOPKSSHOutput(requestId, stderr);
|
||||
}
|
||||
|
||||
if (stderr.includes("provider not found") || stderr.includes("config")) {
|
||||
const lowerStderr = stderr.toLowerCase();
|
||||
|
||||
// OPKSSH's openpubkey library rejects non-localhost `redirect_uris` at runtime
|
||||
// with the distinctive message "redirectURI must be localhost". Surface that
|
||||
// directly with actionable guidance.
|
||||
if (lowerStderr.includes("redirecturi must be localhost")) {
|
||||
sshLogger.warn("OPKSSH rejected non-localhost entry in redirect_uris", {
|
||||
operation: "opkssh_stderr_redirect_uris_not_localhost",
|
||||
requestId,
|
||||
remoteRedirectUri,
|
||||
stderrSnippet: stderr.slice(0, 500),
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_config_error",
|
||||
requestId,
|
||||
error:
|
||||
`OPKSSH rejected the local callback URI: every entry in 'redirect_uris' must be localhost.\n\n` +
|
||||
`OPKSSH output:\n${stderr.trim()}\n\n` +
|
||||
`The 'redirect_uris' config field is OPKSSH's LOCAL listener — it is not the public Termix callback. ` +
|
||||
`Remove any non-localhost entries from redirect_uris (or delete the whole block to use OPKSSH's ` +
|
||||
`defaults of :3000, :10001, :11110). Register the public Termix callback URL with your OAuth ` +
|
||||
`provider instead, Termix passes it to OPKSSH automatically via --remote-redirect-uri.`,
|
||||
instructions: "See documentation: https://docs.termix.site/opkssh",
|
||||
}),
|
||||
);
|
||||
await cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
// Generic redirect-uri/mismatch errors (OAuth provider side, OPKSSH config side, etc.)
|
||||
const genericRedirectIndicators = [
|
||||
"redirect_uri",
|
||||
"redirect uri",
|
||||
"invalid redirect",
|
||||
"no matching redirect",
|
||||
"allowed redirect",
|
||||
"mismatching redirection",
|
||||
];
|
||||
const hasGenericRedirectError = genericRedirectIndicators.some((s) =>
|
||||
lowerStderr.includes(s),
|
||||
);
|
||||
|
||||
if (hasGenericRedirectError) {
|
||||
sshLogger.warn("OPKSSH stderr reported redirect_uri error", {
|
||||
operation: "opkssh_stderr_redirect_uri_error",
|
||||
requestId,
|
||||
remoteRedirectUri,
|
||||
stderrSnippet: stderr.slice(0, 500),
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_config_error",
|
||||
requestId,
|
||||
error:
|
||||
`OPKSSH or the OAuth provider rejected the redirect URI.\n\n` +
|
||||
`Computed Termix callback URI (sent to provider): ${remoteRedirectUri}\n\n` +
|
||||
`OPKSSH output:\n${stderr.trim()}\n\n` +
|
||||
`Register '${remoteRedirectUri}' as an authorized redirect URI with your OAuth provider ` +
|
||||
`(e.g. in Google Cloud Console → OAuth client). ` +
|
||||
`Also confirm any 'redirect_uris' in your OPKSSH config contain ONLY localhost URLs.`,
|
||||
instructions: "See documentation: https://docs.termix.site/opkssh",
|
||||
}),
|
||||
);
|
||||
await cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
stderr.includes("provider not found") ||
|
||||
stderr.includes("config error") ||
|
||||
stderr.includes("invalid config") ||
|
||||
stderr.includes("config not found")
|
||||
) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_config_error",
|
||||
@@ -340,18 +539,18 @@ function handleOPKSSHOutput(requestId: string, output: string): void {
|
||||
session.stdoutBuffer += output;
|
||||
|
||||
const chooserUrlMatch = session.stdoutBuffer.match(
|
||||
/(?:Opening browser to|Open your browser to:)\s*http:\/\/localhost:(\d+)\/chooser/,
|
||||
/(?:Opening browser to|Open your browser to:)\s*http:\/\/(?:localhost|127\.0\.0\.1):(\d+)\/chooser/,
|
||||
);
|
||||
if (chooserUrlMatch && session.status === "starting") {
|
||||
const actualPort = parseInt(chooserUrlMatch[1], 10);
|
||||
const localChooserUrl = `http://localhost:${actualPort}/chooser`;
|
||||
const localChooserUrl = `http://127.0.0.1:${actualPort}/chooser`;
|
||||
|
||||
session.localPort = actualPort;
|
||||
|
||||
const baseUrl = session.remoteRedirectUri.replace(
|
||||
/\/ssh\/opkssh-callback$/,
|
||||
"",
|
||||
);
|
||||
const baseUrl = session.remoteRedirectUri
|
||||
.replace(/\/host\/opkssh-callback$/, "")
|
||||
// In direct dev mode the WS server (30002) is separate from the HTTP API (30001)
|
||||
.replace(/:30002\b/, ":30001");
|
||||
const proxiedChooserUrl = `${baseUrl}/host/opkssh-chooser/${requestId}`;
|
||||
|
||||
session.status = "waiting_for_auth";
|
||||
@@ -361,6 +560,7 @@ function handleOPKSSHOutput(requestId: string, output: string): void {
|
||||
requestId,
|
||||
stage: "chooser",
|
||||
url: proxiedChooserUrl,
|
||||
providers: session.providers,
|
||||
localUrl: localChooserUrl,
|
||||
message: "Please authenticate in your browser",
|
||||
}),
|
||||
@@ -368,7 +568,7 @@ function handleOPKSSHOutput(requestId: string, output: string): void {
|
||||
}
|
||||
|
||||
const callbackPortMatch = session.stdoutBuffer.match(
|
||||
/listening on http:\/\/127\.0\.0\.1:(\d+)\//,
|
||||
/listening on http:\/\/(?:127\.0\.0\.1|localhost):(\d+)\//,
|
||||
);
|
||||
if (callbackPortMatch && !session.callbackPort) {
|
||||
session.callbackPort = parseInt(callbackPortMatch[1], 10);
|
||||
@@ -509,25 +709,6 @@ async function storeOPKSSHToken(session: OPKSSHAuthSession): Promise<void> {
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
"http://localhost:30006/activity/log",
|
||||
{
|
||||
type: "opkssh_authentication",
|
||||
hostId: session.hostId,
|
||||
hostName: session.hostname,
|
||||
status: "approved",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.INTERNAL_AUTH_TOKEN}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (activityError) {
|
||||
sshLogger.warn("Failed to log OPKSSH activity", activityError);
|
||||
}
|
||||
|
||||
await session.cleanup();
|
||||
} catch (error) {
|
||||
sshLogger.error(
|
||||
@@ -657,7 +838,7 @@ export async function handleOAuthCallback(
|
||||
}
|
||||
|
||||
try {
|
||||
const callbackUrl = `http://localhost:${session.localPort}/login-callback?${queryString}`;
|
||||
const callbackUrl = `http://127.0.0.1:${session.localPort}/login-callback?${queryString}`;
|
||||
await axios.get(callbackUrl, {
|
||||
timeout: 10000,
|
||||
validateStatus: () => true,
|
||||
@@ -720,6 +901,13 @@ async function cleanupAuthSession(requestId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any OAuth state mappings for this session
|
||||
for (const [state, reqId] of oauthStateToRequestId.entries()) {
|
||||
if (reqId === requestId) {
|
||||
oauthStateToRequestId.delete(state);
|
||||
}
|
||||
}
|
||||
|
||||
activeAuthSessions.delete(requestId);
|
||||
} finally {
|
||||
cleanupInProgress.delete(requestId);
|
||||
@@ -753,6 +941,18 @@ export function getActiveSessionsAll(): OPKSSHAuthSession[] {
|
||||
return Array.from(activeAuthSessions.values());
|
||||
}
|
||||
|
||||
export function registerOAuthState(state: string, requestId: string): void {
|
||||
oauthStateToRequestId.set(state, requestId);
|
||||
}
|
||||
|
||||
export function getRequestIdByOAuthState(state: string): string | undefined {
|
||||
return oauthStateToRequestId.get(state);
|
||||
}
|
||||
|
||||
export function clearOAuthState(state: string): void {
|
||||
oauthStateToRequestId.delete(state);
|
||||
}
|
||||
|
||||
export async function getUserIdFromRequest(req: {
|
||||
cookies?: Record<string, string>;
|
||||
headers: Record<string, string | undefined>;
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
// OPKSSH certificate authentication workarounds for ssh2.
|
||||
// ssh2 doesn't support OpenSSH cert auth natively — this module grafts
|
||||
// the certificate onto the parsed key, wraps ECDSA signing to convert
|
||||
// DER → SSH wire format, and patches Protocol.authPK to use the base
|
||||
// algorithm in the signature wrapper (required by OpenSSH's sshkey_check_sigtype).
|
||||
|
||||
import type {
|
||||
AnyAuthMethod,
|
||||
AuthHandlerMiddleware,
|
||||
AuthenticationType,
|
||||
Client,
|
||||
ConnectConfig,
|
||||
PublicKeyAuthMethod,
|
||||
} from "ssh2";
|
||||
|
||||
interface OPKSSHToken {
|
||||
privateKey: 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(
|
||||
config: ConnectConfig,
|
||||
client: Client,
|
||||
token: OPKSSHToken,
|
||||
username: string,
|
||||
): Promise<void> {
|
||||
const { createRequire } = await import("node:module");
|
||||
const esmRequire = createRequire(import.meta.url);
|
||||
const {
|
||||
utils: { parseKey },
|
||||
} = esmRequire("ssh2");
|
||||
|
||||
const parsed = parseKey(Buffer.from(token.privateKey));
|
||||
if (parsed instanceof Error || !parsed) {
|
||||
throw new Error("Failed to parse OPKSSH private key");
|
||||
}
|
||||
const privKey = (
|
||||
Array.isArray(parsed) ? parsed[0] : parsed
|
||||
) as ParsedPrivateKey;
|
||||
|
||||
// Extract cert type and blob from the stored certificate
|
||||
const certParts = token.sshCert.trim().split(/\s+/);
|
||||
const certType = certParts[0];
|
||||
privKey.type = certType;
|
||||
const certBlob = Buffer.from(certParts[1], "base64");
|
||||
|
||||
// Replace the internal public SSH blob with the full certificate
|
||||
const pubSSHSym = Object.getOwnPropertySymbols(privKey).find(
|
||||
(s) => String(s) === "Symbol(Public key SSH)",
|
||||
);
|
||||
if (!pubSSHSym) {
|
||||
throw new Error(
|
||||
"Cannot find public SSH symbol on parsed key, ssh2 internals may have changed",
|
||||
);
|
||||
}
|
||||
privKey[pubSSHSym] = certBlob;
|
||||
|
||||
// Wrap sign() for ECDSA cert keys
|
||||
if (privKey.type.startsWith("ecdsa-")) {
|
||||
const origSign = privKey.sign.bind(privKey);
|
||||
privKey.sign = (data: Buffer, algo?: string) => {
|
||||
const sigAlgo = algo?.includes("-cert-")
|
||||
? algo.replace(/-cert-v\d+@openssh\.com$/, "")
|
||||
: algo;
|
||||
const sig = origSign(data, sigAlgo);
|
||||
if (sig instanceof Error || sig[0] !== 0x30) return sig;
|
||||
// Convert DER-encoded ECDSA signature to SSH wire format
|
||||
try {
|
||||
let pos = 2;
|
||||
if (sig[1] & 0x80) pos += sig[1] & 0x7f;
|
||||
pos++;
|
||||
const rLen = sig[pos++];
|
||||
const r = sig.subarray(pos, pos + rLen);
|
||||
pos += rLen + 1;
|
||||
const sLen = sig[pos++];
|
||||
const s = sig.subarray(pos, pos + sLen);
|
||||
const out = Buffer.allocUnsafe(4 + r.length + 4 + s.length);
|
||||
out.writeUInt32BE(r.length, 0);
|
||||
r.copy(out, 4);
|
||||
out.writeUInt32BE(s.length, 4 + r.length);
|
||||
s.copy(out, 4 + r.length + 4);
|
||||
return out;
|
||||
} catch {
|
||||
return sig;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Set up authHandler to bypass ssh2's cert type rejection
|
||||
let certAuthAttempted = false;
|
||||
const authHandler: AuthHandlerMiddleware = (
|
||||
methodsLeft: string[],
|
||||
_partialSuccess: boolean,
|
||||
callback,
|
||||
) => {
|
||||
const next = callback as OPKSSHNextAuthHandler;
|
||||
if (
|
||||
!certAuthAttempted &&
|
||||
(!methodsLeft || methodsLeft.includes("publickey"))
|
||||
) {
|
||||
certAuthAttempted = true;
|
||||
next({
|
||||
type: "publickey",
|
||||
username,
|
||||
key: privKey as unknown as PublicKeyAuthMethod["key"],
|
||||
});
|
||||
} else {
|
||||
next(false);
|
||||
}
|
||||
};
|
||||
config.authHandler = authHandler;
|
||||
|
||||
// Monkey-patch Protocol.authPK after connect() to fix the signature
|
||||
// wrapper algorithm for cert types.
|
||||
const baseAlgo = certType.replace(/-cert-v\d+@openssh\.com$/, "");
|
||||
const origConnect = client.connect.bind(client);
|
||||
const patchedClient = client as OPKSSHClient;
|
||||
patchedClient.connect = (cfg: ConnectConfig) => {
|
||||
const connectedClient = origConnect(cfg);
|
||||
const proto = patchedClient._protocol;
|
||||
if (!proto) return connectedClient;
|
||||
const origAuthPK = proto.authPK.bind(proto);
|
||||
proto.authPK = (
|
||||
user: string,
|
||||
pubKey: ParsedPrivateKey,
|
||||
keyAlgo: string | undefined,
|
||||
cbSign?: SignCallback,
|
||||
) => {
|
||||
const isCertAuth = !!cbSign && pubKey?.type?.includes("-cert-");
|
||||
if (!isCertAuth) {
|
||||
return origAuthPK(user, pubKey, keyAlgo, cbSign);
|
||||
}
|
||||
|
||||
// Signed auth with cert type: rebuild packet with base algo in
|
||||
// the signature wrapper. keyAlgo may be undefined for ECDSA.
|
||||
const certAlgo = keyAlgo || pubKey.type;
|
||||
const pubSSH = pubKey.getPublicSSH();
|
||||
const sessionID = proto._kex.sessionID;
|
||||
const sesLen = sessionID.length;
|
||||
const userLen = Buffer.byteLength(user);
|
||||
const certAlgoLen = Buffer.byteLength(certAlgo);
|
||||
const baseAlgoLen = Buffer.byteLength(baseAlgo);
|
||||
const pubKeyLen = pubSSH.length;
|
||||
|
||||
// Build data to sign (uses cert algo — matches server verification)
|
||||
const sigDataLen =
|
||||
4 +
|
||||
sesLen +
|
||||
1 +
|
||||
4 +
|
||||
userLen +
|
||||
4 +
|
||||
14 +
|
||||
4 +
|
||||
9 +
|
||||
1 +
|
||||
4 +
|
||||
certAlgoLen +
|
||||
4 +
|
||||
pubKeyLen;
|
||||
const sigData = Buffer.allocUnsafe(sigDataLen);
|
||||
let sp = 0;
|
||||
sigData.writeUInt32BE(sesLen, sp);
|
||||
sp += 4;
|
||||
sessionID.copy(sigData, sp);
|
||||
sp += sesLen;
|
||||
sigData[sp++] = 50; // SSH_MSG_USERAUTH_REQUEST
|
||||
sigData.writeUInt32BE(userLen, sp);
|
||||
sp += 4;
|
||||
sigData.write(user, sp, userLen, "utf8");
|
||||
sp += userLen;
|
||||
sigData.writeUInt32BE(14, sp);
|
||||
sp += 4;
|
||||
sigData.write("ssh-connection", sp, 14, "utf8");
|
||||
sp += 14;
|
||||
sigData.writeUInt32BE(9, sp);
|
||||
sp += 4;
|
||||
sigData.write("publickey", sp, 9, "utf8");
|
||||
sp += 9;
|
||||
sigData[sp++] = 1; // TRUE
|
||||
sigData.writeUInt32BE(certAlgoLen, sp);
|
||||
sp += 4;
|
||||
sigData.write(certAlgo, sp, certAlgoLen, "utf8");
|
||||
sp += certAlgoLen;
|
||||
sigData.writeUInt32BE(pubKeyLen, sp);
|
||||
sp += 4;
|
||||
pubSSH.copy(sigData, sp);
|
||||
|
||||
cbSign(sigData, (signature: Buffer) => {
|
||||
const sigLen = signature.length;
|
||||
const payloadLen =
|
||||
1 +
|
||||
4 +
|
||||
userLen +
|
||||
4 +
|
||||
14 +
|
||||
4 +
|
||||
9 +
|
||||
1 +
|
||||
4 +
|
||||
certAlgoLen +
|
||||
4 +
|
||||
pubKeyLen +
|
||||
4 +
|
||||
4 +
|
||||
baseAlgoLen +
|
||||
4 +
|
||||
sigLen;
|
||||
const packet = proto._packetRW.write.alloc(payloadLen);
|
||||
let pp = proto._packetRW.write.allocStart;
|
||||
packet[pp] = 50; // SSH_MSG_USERAUTH_REQUEST
|
||||
packet.writeUInt32BE(userLen, ++pp);
|
||||
pp += 4;
|
||||
packet.write(user, pp, userLen, "utf8");
|
||||
pp += userLen;
|
||||
packet.writeUInt32BE(14, pp);
|
||||
pp += 4;
|
||||
packet.write("ssh-connection", pp, 14, "utf8");
|
||||
pp += 14;
|
||||
packet.writeUInt32BE(9, pp);
|
||||
pp += 4;
|
||||
packet.write("publickey", pp, 9, "utf8");
|
||||
pp += 9;
|
||||
packet[pp++] = 1; // TRUE
|
||||
// Header: cert type
|
||||
packet.writeUInt32BE(certAlgoLen, pp);
|
||||
pp += 4;
|
||||
packet.write(certAlgo, pp, certAlgoLen, "utf8");
|
||||
pp += certAlgoLen;
|
||||
// Public key blob
|
||||
packet.writeUInt32BE(pubKeyLen, pp);
|
||||
pp += 4;
|
||||
pubSSH.copy(packet, pp);
|
||||
pp += pubKeyLen;
|
||||
// Signature wrapper: base algo (NOT cert type)
|
||||
packet.writeUInt32BE(4 + baseAlgoLen + 4 + sigLen, pp);
|
||||
pp += 4;
|
||||
packet.writeUInt32BE(baseAlgoLen, pp);
|
||||
pp += 4;
|
||||
packet.write(baseAlgo, pp, baseAlgoLen, "utf8");
|
||||
pp += baseAlgoLen;
|
||||
packet.writeUInt32BE(sigLen, pp);
|
||||
pp += 4;
|
||||
signature.copy(packet, pp);
|
||||
|
||||
proto._authsQueue.push("publickey");
|
||||
proto._debug?.("Outbound: Sending USERAUTH_REQUEST (publickey)");
|
||||
const finalized = proto._packetRW.write.finalize(packet);
|
||||
proto._cipher.encrypt(finalized);
|
||||
});
|
||||
};
|
||||
return connectedClient;
|
||||
};
|
||||
}
|
||||
+178
-63
@@ -1,8 +1,9 @@
|
||||
import express from "express";
|
||||
import net from "net";
|
||||
import cors from "cors";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { Client, type ConnectConfig } from "ssh2";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
@@ -31,7 +32,9 @@ import { connectionPool, withConnection } from "./ssh-connection-pool.js";
|
||||
|
||||
function supportsMetrics(host: SSHHostWithCredentials): boolean {
|
||||
const connectionType = host.connectionType || "ssh";
|
||||
return connectionType === "ssh";
|
||||
if (connectionType !== "ssh") return false;
|
||||
if (host.authType === "none" || host.authType === "opkssh") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isTcpPingEnabled(statsConfig: StatsConfig): boolean {
|
||||
@@ -865,7 +868,15 @@ class PollingManager {
|
||||
latestConfig.statsConfig.metricsEnabled &&
|
||||
supportsMetrics(latestConfig.host)
|
||||
) {
|
||||
this.pollHostMetrics(latestConfig.host, latestConfig.viewerUserId);
|
||||
this.pollHostMetrics(
|
||||
latestConfig.host,
|
||||
latestConfig.viewerUserId,
|
||||
).catch((err) => {
|
||||
statsLogger.error("Metrics polling failed", err, {
|
||||
operation: "metrics_poll_unhandled",
|
||||
hostId: host.id,
|
||||
});
|
||||
});
|
||||
}
|
||||
}, intervalMs);
|
||||
} else {
|
||||
@@ -929,9 +940,11 @@ class PollingManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasExistingMetrics = this.metricsStore.has(refreshedHost.id);
|
||||
if (authFailureTracker.shouldSkip(host.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasExistingMetrics && pollingBackoff.shouldSkip(host.id)) {
|
||||
if (pollingBackoff.shouldSkip(host.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -942,16 +955,38 @@ class PollingManager {
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
pollingBackoff.reset(refreshedHost.id);
|
||||
authFailureTracker.reset(refreshedHost.id);
|
||||
} catch (error) {
|
||||
const isAuthError =
|
||||
error instanceof Error &&
|
||||
(error.message.includes("authentication") ||
|
||||
error.message.includes("Authentication") ||
|
||||
error.message.includes("permission denied") ||
|
||||
error.message.includes("Permission denied"));
|
||||
|
||||
if (isAuthError) {
|
||||
// authFailureTracker already handles auth errors inside collectMetrics;
|
||||
// only log on the first occurrence to avoid repeated spam
|
||||
const alreadyTracked = authFailureTracker.shouldSkip(host.id);
|
||||
if (!alreadyTracked) {
|
||||
statsLogger.error("Stats collector connection failed", error, {
|
||||
operation: "stats_connect_failed",
|
||||
hostId: refreshedHost.id,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pollingBackoff.recordFailure(refreshedHost.id);
|
||||
|
||||
const latestConfig = this.pollingConfigs.get(refreshedHost.id);
|
||||
if (latestConfig && latestConfig.statsConfig.metricsEnabled) {
|
||||
const backoffInfo = pollingBackoff.getBackoffInfo(refreshedHost.id);
|
||||
// Only log when a new retry window opens, not on every skipped poll
|
||||
const backoff = pollingBackoff.getBackoffInfo(refreshedHost.id);
|
||||
const isNewFailure =
|
||||
backoff !== null && !backoff.includes("polling suspended");
|
||||
if (isNewFailure) {
|
||||
statsLogger.error("Stats collector connection failed", error, {
|
||||
operation: "stats_connect_failed",
|
||||
hostId: refreshedHost.id,
|
||||
retryInfo: backoffInfo,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1053,8 +1088,6 @@ class PollingManager {
|
||||
for (const { host, viewerUserId } of hostsToRefresh) {
|
||||
await this.startPollingForHost(host, { statusOnly: true, viewerUserId });
|
||||
}
|
||||
|
||||
const skipped = this.pollingConfigs.size - hostsToRefresh.length;
|
||||
}
|
||||
|
||||
registerViewer(hostId: number, sessionId: string, userId: string): void {
|
||||
@@ -1071,7 +1104,18 @@ class PollingManager {
|
||||
});
|
||||
|
||||
if (this.activeViewers.get(hostId)!.size === 1) {
|
||||
this.startMetricsForHost(hostId, userId);
|
||||
// Fire-and-forget: never let background metrics start-up failures
|
||||
// propagate up to the HTTP handler that registered the viewer.
|
||||
Promise.resolve()
|
||||
.then(() => this.startMetricsForHost(hostId, userId))
|
||||
.catch((err) => {
|
||||
statsLogger.warn("startMetricsForHost rejected (non-fatal)", {
|
||||
operation: "start_metrics_unhandled",
|
||||
hostId,
|
||||
userId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1153,37 +1197,7 @@ function validateHostId(
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
const allowedOrigins = ["http://localhost:5173", "http://127.0.0.1:5173"];
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("https://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("http://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"X-Electron-App",
|
||||
],
|
||||
}),
|
||||
);
|
||||
app.use(createCorsMiddleware());
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use((_req, res, next) => {
|
||||
@@ -1373,8 +1387,13 @@ async function resolveHostCredentials(
|
||||
if (credential.password) {
|
||||
baseHost.password = credential.password;
|
||||
}
|
||||
if (credential.key) {
|
||||
baseHost.key = credential.key;
|
||||
if (
|
||||
credential.key ||
|
||||
(credential as Record<string, unknown>).privateKey
|
||||
) {
|
||||
baseHost.key =
|
||||
credential.key ||
|
||||
((credential as Record<string, unknown>).privateKey as string);
|
||||
}
|
||||
if (credential.keyPassword) {
|
||||
baseHost.keyPassword = credential.keyPassword;
|
||||
@@ -1485,18 +1504,7 @@ async function buildSshConfig(
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: [
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
"aes256-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
cipher: SSH_ALGORITHMS.cipher,
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
@@ -1541,12 +1549,14 @@ async function buildSshConfig(
|
||||
statsLogger.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") {
|
||||
// no credentials needed
|
||||
} else if (host.authType === "opkssh") {
|
||||
// handled externally
|
||||
// cert auth setup happens in createSshFactory (needs client instance)
|
||||
} else if (host.authType === "credential") {
|
||||
if (host.password) {
|
||||
base.password = host.password;
|
||||
@@ -1586,6 +1596,19 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
|
||||
const config = await buildSshConfig(host);
|
||||
const client = new Client();
|
||||
|
||||
// Set up OPKSSH cert auth if needed (requires client instance)
|
||||
if (host.authType === "opkssh" && host.userId) {
|
||||
const { getOPKSSHToken } = await import("./opkssh-auth.js");
|
||||
const token = await getOPKSSHToken(host.userId, host.id);
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"OPKSSH authentication required. Please open a Terminal connection first.",
|
||||
);
|
||||
}
|
||||
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
|
||||
await setupOPKSSHCertAuth(config, client, token, host.username);
|
||||
}
|
||||
|
||||
const proxyConfig: SOCKS5Config | null =
|
||||
host.useSocks5 &&
|
||||
(host.socks5Host ||
|
||||
@@ -1630,6 +1653,7 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
|
||||
(proxyError instanceof Error
|
||||
? proxyError.message
|
||||
: "Unknown error"),
|
||||
{ cause: proxyError },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1913,7 +1937,8 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
} else if (
|
||||
error.message.includes("No password available") ||
|
||||
error.message.includes("Unsupported authentication type") ||
|
||||
error.message.includes("No SSH key available")
|
||||
error.message.includes("No SSH key available") ||
|
||||
error.message.includes("Invalid SSH key format")
|
||||
) {
|
||||
authFailureTracker.recordFailure(host.id, "AUTH", true);
|
||||
} else if (
|
||||
@@ -2413,6 +2438,27 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
||||
const config = await buildSshConfig(host);
|
||||
const client = new Client();
|
||||
|
||||
if (host.authType === "opkssh" && host.userId) {
|
||||
const { getOPKSSHToken } = await import("./opkssh-auth.js");
|
||||
const token = await getOPKSSHToken(host.userId, host.id);
|
||||
if (!token) {
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"error",
|
||||
"auth",
|
||||
"OPKSSH authentication required. Please open a Terminal connection first.",
|
||||
),
|
||||
);
|
||||
return res.status(401).json({
|
||||
error: "OPKSSH authentication required",
|
||||
requiresOPKSSHAuth: true,
|
||||
connectionLogs,
|
||||
});
|
||||
}
|
||||
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
|
||||
await setupOPKSSHCertAuth(config, client, token, host.username);
|
||||
}
|
||||
|
||||
const connectionPromise = new Promise<{
|
||||
success: boolean;
|
||||
requires_totp?: boolean;
|
||||
@@ -2535,7 +2581,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
let errorStage: ConnectionStage = "error";
|
||||
let errorStage: ConnectionStage;
|
||||
|
||||
if (
|
||||
errorMessage.includes("ENOTFOUND") ||
|
||||
@@ -3028,8 +3074,70 @@ app.post("/metrics/register-viewer", async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Graceful no-op if host is inaccessible, metrics disabled, or host type
|
||||
// does not support metrics. The client may call this speculatively, so
|
||||
// avoid returning 5xx for expected "no metrics available" scenarios.
|
||||
let host: SSHHostWithCredentials | undefined;
|
||||
try {
|
||||
host = await fetchHostById(hostId, userId);
|
||||
} catch (lookupErr) {
|
||||
statsLogger.warn(
|
||||
"register-viewer host lookup failed (treating as no-op)",
|
||||
{
|
||||
operation: "register_viewer_lookup",
|
||||
hostId,
|
||||
userId,
|
||||
error:
|
||||
lookupErr instanceof Error ? lookupErr.message : String(lookupErr),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
return res.json({
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: "host_not_found",
|
||||
});
|
||||
}
|
||||
|
||||
if (!supportsMetrics(host)) {
|
||||
return res.json({
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: "metrics_unsupported",
|
||||
});
|
||||
}
|
||||
|
||||
const statsConfig = pollingManager.parseStatsConfig(host.statsConfig);
|
||||
if (!statsConfig.metricsEnabled) {
|
||||
return res.json({
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: "metrics_disabled",
|
||||
});
|
||||
}
|
||||
|
||||
const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
pollingManager.registerViewer(hostId, viewerSessionId, userId);
|
||||
try {
|
||||
pollingManager.registerViewer(hostId, viewerSessionId, userId);
|
||||
} catch (regErr) {
|
||||
statsLogger.warn(
|
||||
"pollingManager.registerViewer threw (treating as no-op)",
|
||||
{
|
||||
operation: "register_viewer_internal",
|
||||
hostId,
|
||||
userId,
|
||||
error: regErr instanceof Error ? regErr.message : String(regErr),
|
||||
},
|
||||
);
|
||||
return res.json({
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: "register_failed_noop",
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, viewerSessionId });
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to register viewer", {
|
||||
@@ -3038,7 +3146,14 @@ app.post("/metrics/register-viewer", async (req, res) => {
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(500).json({ error: "Failed to register viewer" });
|
||||
// Even on unexpected errors we prefer a graceful client experience: the
|
||||
// viewer-registration is purely an optimization and should never break
|
||||
// the UI. Report success:false but HTTP 200 so the client can decide.
|
||||
res.status(200).json({
|
||||
success: false,
|
||||
skipped: true,
|
||||
reason: "internal_error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface TerminalSession {
|
||||
sshConn: Client | null;
|
||||
sshStream: ClientChannel | null;
|
||||
jumpClient: Client | null;
|
||||
opksshTempFiles: { keyPath: string; certPath: string } | null;
|
||||
|
||||
cols: number;
|
||||
rows: number;
|
||||
@@ -32,6 +31,7 @@ export interface TerminalSession {
|
||||
|
||||
outputBuffer: string[];
|
||||
outputBufferBytes: number;
|
||||
tmuxSessionName: string | null;
|
||||
}
|
||||
|
||||
class TerminalSessionManager {
|
||||
@@ -99,7 +99,6 @@ class TerminalSessionManager {
|
||||
sshConn: null,
|
||||
sshStream: null,
|
||||
jumpClient: null,
|
||||
opksshTempFiles: null,
|
||||
cols,
|
||||
rows,
|
||||
isConnected: false,
|
||||
@@ -109,6 +108,7 @@ class TerminalSessionManager {
|
||||
detachTimeout: null,
|
||||
outputBuffer: [],
|
||||
outputBufferBytes: 0,
|
||||
tmuxSessionName: null,
|
||||
};
|
||||
this.sessions.set(id, session);
|
||||
|
||||
@@ -132,7 +132,6 @@ class TerminalSessionManager {
|
||||
conn: Client,
|
||||
stream: ClientChannel,
|
||||
jumpClient?: Client | null,
|
||||
opksshTempFiles?: { keyPath: string; certPath: string } | null,
|
||||
): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
@@ -140,7 +139,6 @@ class TerminalSessionManager {
|
||||
session.sshConn = conn;
|
||||
session.sshStream = stream;
|
||||
session.jumpClient = jumpClient ?? null;
|
||||
session.opksshTempFiles = opksshTempFiles ?? null;
|
||||
session.isConnected = true;
|
||||
}
|
||||
|
||||
@@ -326,12 +324,6 @@ class TerminalSessionManager {
|
||||
session.jumpClient = null;
|
||||
}
|
||||
|
||||
if (session.opksshTempFiles) {
|
||||
const tempFiles = session.opksshTempFiles;
|
||||
session.opksshTempFiles = null;
|
||||
this.cleanupOpksshFiles(tempFiles);
|
||||
}
|
||||
|
||||
session.isConnected = false;
|
||||
session.outputBuffer = [];
|
||||
session.outputBufferBytes = 0;
|
||||
@@ -449,32 +441,6 @@ class TerminalSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupOpksshFiles(tempFiles: {
|
||||
keyPath: string;
|
||||
certPath: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const { promises: fs } = await import("fs");
|
||||
const results = await Promise.allSettled([
|
||||
fs.unlink(tempFiles.keyPath),
|
||||
fs.unlink(tempFiles.certPath),
|
||||
]);
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === "rejected") {
|
||||
sshLogger.warn("Failed to cleanup OPKSSH temp file", {
|
||||
operation: "opkssh_temp_cleanup_failed",
|
||||
file: index === 0 ? "keyPath" : "certPath",
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to cleanup OPKSSH temp files", {
|
||||
operation: "opkssh_temp_cleanup_error",
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
destroyAll(): void {
|
||||
for (const id of [...this.sessions.keys()]) {
|
||||
this.destroySession(id);
|
||||
|
||||
+454
-141
@@ -1,6 +1,8 @@
|
||||
import { WebSocketServer, WebSocket, type RawData } from "ws";
|
||||
import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
|
||||
import { parse as parseUrl } from "url";
|
||||
import net from "net";
|
||||
import dgram from "dgram";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import axios from "axios";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sshCredentials, hosts } from "../database/db/schema.js";
|
||||
@@ -17,6 +19,46 @@ import { SSHAuthManager } from "./auth-manager.js";
|
||||
import type { ProxyNode } from "../../types/index.js";
|
||||
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
|
||||
import { sessionManager } from "./terminal-session-manager.js";
|
||||
import {
|
||||
detectTmux,
|
||||
attachOrCreateTmuxSession,
|
||||
queryNewestTmuxSession,
|
||||
} from "./tmux-helper.js";
|
||||
|
||||
async function performPortKnocking(
|
||||
host: string,
|
||||
sequence: Array<{ port: number; protocol?: string; delay?: number }>,
|
||||
): Promise<void> {
|
||||
for (const knock of sequence) {
|
||||
const protocol = knock.protocol || "tcp";
|
||||
const delay = knock.delay ?? 100;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
if (protocol === "udp") {
|
||||
const client = dgram.createSocket("udp4");
|
||||
client.send(Buffer.alloc(0), knock.port, host, () => {
|
||||
client.close();
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
const socket = new net.Socket();
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.once("error", () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.connect(knock.port, host);
|
||||
}
|
||||
});
|
||||
|
||||
if (delay > 0) {
|
||||
await new Promise<void>((r) => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ConnectToHostData {
|
||||
cols: number;
|
||||
@@ -42,6 +84,11 @@ interface ConnectToHostData {
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: unknown;
|
||||
portKnockSequence?: Array<{
|
||||
port: number;
|
||||
protocol?: "tcp" | "udp";
|
||||
delay?: number;
|
||||
}>;
|
||||
terminalConfig?: {
|
||||
keepaliveInterval?: number;
|
||||
keepaliveCountMax?: number;
|
||||
@@ -255,7 +302,7 @@ async function createJumpHostChain(
|
||||
host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
|
||||
port: jumpHostConfig.port || 22,
|
||||
username: jumpHostConfig.username,
|
||||
tryKeyboard: true,
|
||||
tryKeyboard: jumpHostConfig.authType !== "none",
|
||||
readyTimeout: 30000,
|
||||
hostVerifier: jumpHostVerifier,
|
||||
};
|
||||
@@ -319,8 +366,20 @@ const wss = new WebSocketServer({
|
||||
port: 30002,
|
||||
verifyClient: async (info) => {
|
||||
try {
|
||||
const url = parseUrl(info.req.url!, true);
|
||||
const token = url.query.token as string;
|
||||
let token: string | undefined;
|
||||
|
||||
const cookieHeader = info.req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
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) {
|
||||
return false;
|
||||
@@ -338,7 +397,7 @@ const wss = new WebSocketServer({
|
||||
|
||||
const existingConnections = userConnections.get(payload.userId);
|
||||
|
||||
if (existingConnections && existingConnections.size >= 3) {
|
||||
if (existingConnections && existingConnections.size >= 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -358,8 +417,20 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
let sessionId: string | undefined;
|
||||
|
||||
try {
|
||||
const url = parseUrl(req.url!, true);
|
||||
const token = url.query.token as string;
|
||||
let token: string | undefined;
|
||||
|
||||
const cookieHeader = req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) token = decodeURIComponent(match[1]);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
token = authHeader.slice("Bearer ".length);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
ws.close(1008, "Authentication required");
|
||||
@@ -423,14 +494,34 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
let isConnecting = false;
|
||||
let isConnected = false;
|
||||
let isCleaningUp = false;
|
||||
let cwdPending = false;
|
||||
let cwdBuffer = "";
|
||||
let isShellInitializing = false;
|
||||
let warpgateAuthPromptSent = false;
|
||||
let warpgateAuthTimeout: NodeJS.Timeout | null = null;
|
||||
let isAwaitingAuthCredentials = false;
|
||||
let opksshTempFiles: { keyPath: string; certPath: string } | null = null;
|
||||
|
||||
let wsAlive = true;
|
||||
|
||||
ws.on("pong", () => {
|
||||
wsAlive = true;
|
||||
});
|
||||
|
||||
const wsPingInterval = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
if (!wsAlive) {
|
||||
sshLogger.warn(
|
||||
"WebSocket pong timeout - terminating zombie connection",
|
||||
{
|
||||
operation: "ws_pong_timeout",
|
||||
userId,
|
||||
sessionId: currentSessionId,
|
||||
},
|
||||
);
|
||||
ws.terminate();
|
||||
return;
|
||||
}
|
||||
wsAlive = false;
|
||||
ws.ping();
|
||||
}
|
||||
}, 30000);
|
||||
@@ -508,6 +599,22 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
connectData.hostConfig.userId = userId;
|
||||
}
|
||||
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, {
|
||||
operation: "ssh_connect",
|
||||
userId,
|
||||
@@ -517,9 +624,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"Failed to connect to host: " +
|
||||
(error instanceof Error ? error.message : "Unknown error"),
|
||||
message: "Failed to connect to host: " + errMsg,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -624,6 +729,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
hostName: s.hostName,
|
||||
createdAt: s.createdAt,
|
||||
lastDetachedAt: s.lastDetachedAt,
|
||||
tmuxSessionName: s.tmuxSessionName,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
@@ -646,6 +752,21 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
sshStream = null;
|
||||
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": {
|
||||
const inputData = data as string;
|
||||
const inputStream =
|
||||
@@ -678,6 +799,52 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
break;
|
||||
|
||||
case "tmux_attach": {
|
||||
const tmuxData = data as { sessionName: string };
|
||||
const session = currentSessionId
|
||||
? sessionManager.getSession(currentSessionId)
|
||||
: null;
|
||||
if (session?.sshStream) {
|
||||
const existingName = tmuxData.sessionName || undefined;
|
||||
attachOrCreateTmuxSession(session.sshStream, existingName);
|
||||
if (existingName) {
|
||||
session.tmuxSessionName = existingName;
|
||||
sshLogger.info("User selected tmux session to attach", {
|
||||
operation: "tmux_user_attach",
|
||||
sessionName: existingName,
|
||||
hostId: session.hostId,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_session_attached",
|
||||
sessionName: existingName,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// New session from picker -- query name after startup
|
||||
const sshConn = session.sshConn;
|
||||
setTimeout(async () => {
|
||||
const sessionName = sshConn
|
||||
? await queryNewestTmuxSession(sshConn)
|
||||
: null;
|
||||
session.tmuxSessionName = sessionName;
|
||||
sshLogger.info("User requested new tmux session", {
|
||||
operation: "tmux_user_create",
|
||||
sessionName,
|
||||
hostId: session.hostId,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_session_created",
|
||||
sessionName,
|
||||
}),
|
||||
);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "totp_response": {
|
||||
const totpData = data as TOTPResponseData;
|
||||
if (keyboardInteractiveFinish && totpData?.code) {
|
||||
@@ -769,6 +936,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
credentialsData.hostConfig.key = credentialsData.sshKey;
|
||||
credentialsData.hostConfig.keyPassword = credentialsData.keyPassword;
|
||||
credentialsData.hostConfig.authType = "key";
|
||||
} else if (credentialsData.keyPassword) {
|
||||
credentialsData.hostConfig.keyPassword = credentialsData.keyPassword;
|
||||
}
|
||||
|
||||
isAwaitingAuthCredentials = false;
|
||||
@@ -787,6 +956,22 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
};
|
||||
|
||||
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, {
|
||||
operation: "ssh_reconnect_with_credentials",
|
||||
userId,
|
||||
@@ -796,9 +981,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"Failed to connect with provided credentials: " +
|
||||
(error instanceof Error ? error.message : "Unknown error"),
|
||||
message: "Failed to connect with provided credentials: " + errMsg,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1048,6 +1231,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
// Resolve credentials server-side when frontend doesn't provide them
|
||||
let resolvedCredentials = {
|
||||
username,
|
||||
password,
|
||||
@@ -1057,94 +1241,53 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
authType,
|
||||
};
|
||||
const authMethodNotAvailable = false;
|
||||
if (credentialId && id) {
|
||||
const hostRow = await getDb()
|
||||
.select({ userId: hosts.userId })
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, id))
|
||||
.limit(1);
|
||||
const ownerId = hostRow[0]?.userId ?? null;
|
||||
|
||||
if (ownerId && userId !== ownerId) {
|
||||
try {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||
id,
|
||||
userId,
|
||||
if (id && userId && !password && !key) {
|
||||
try {
|
||||
const { resolveHostById } = await import("./host-resolver.js");
|
||||
const resolvedHost = await resolveHostById(id, userId);
|
||||
if (resolvedHost) {
|
||||
resolvedCredentials = {
|
||||
username: resolvedHost.username || username,
|
||||
password: resolvedHost.password,
|
||||
key: resolvedHost.key,
|
||||
keyPassword: keyPassword || resolvedHost.keyPassword,
|
||||
keyType: resolvedHost.keyType,
|
||||
authType: resolvedHost.authType,
|
||||
};
|
||||
sendLog(
|
||||
"auth",
|
||||
"info",
|
||||
"Credentials resolved from server-side host data",
|
||||
);
|
||||
|
||||
if (sharedCred) {
|
||||
resolvedCredentials = {
|
||||
username: sharedCred.username || username,
|
||||
password: sharedCred.password,
|
||||
key: sharedCred.key,
|
||||
keyPassword: sharedCred.keyPassword,
|
||||
keyType: sharedCred.keyType,
|
||||
authType: sharedCred.authType,
|
||||
};
|
||||
} else {
|
||||
sshLogger.warn(`No shared credentials found for host ${id}`, {
|
||||
operation: "ssh_credentials",
|
||||
userId,
|
||||
hostId: id,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.warn(`Failed to resolve shared credential for host ${id}`, {
|
||||
operation: "ssh_credentials",
|
||||
hostId: id,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
} else if (ownerId) {
|
||||
try {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, ownerId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
ownerId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedCredentials = {
|
||||
username: (credential.username as string | undefined) || username,
|
||||
password: credential.password as string | undefined,
|
||||
key: credential.privateKey as string | undefined,
|
||||
keyPassword: credential.keyPassword as string | undefined,
|
||||
keyType: credential.keyType as string | undefined,
|
||||
authType: credential.authType as string | undefined,
|
||||
};
|
||||
} else {
|
||||
sshLogger.warn(`No credentials found for host ${id}`, {
|
||||
operation: "ssh_credentials",
|
||||
hostId: id,
|
||||
credentialId,
|
||||
userId: ownerId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.warn(`Failed to resolve credentials for host ${id}`, {
|
||||
operation: "ssh_credentials",
|
||||
hostId: id,
|
||||
credentialId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.warn(`Failed to resolve host credentials for ${id}`, {
|
||||
operation: "ssh_credentials",
|
||||
hostId: id,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
} else if (credentialId && id && userId) {
|
||||
try {
|
||||
const { resolveHostById } = await import("./host-resolver.js");
|
||||
const resolvedHost = await resolveHostById(id, userId);
|
||||
if (resolvedHost) {
|
||||
resolvedCredentials = {
|
||||
username: resolvedHost.username || username,
|
||||
password: resolvedHost.password,
|
||||
key: resolvedHost.key,
|
||||
// Preserve user-supplied keyPassword (e.g. from passphrase dialog) over the empty DB value
|
||||
keyPassword: keyPassword || resolvedHost.keyPassword,
|
||||
keyType: resolvedHost.keyType,
|
||||
authType: resolvedHost.authType,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
sshLogger.warn("Missing userId for credential resolution in terminal", {
|
||||
} catch (error) {
|
||||
sshLogger.warn(`Failed to resolve credentials for host ${id}`, {
|
||||
operation: "ssh_credentials",
|
||||
hostId: id,
|
||||
credentialId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1331,7 +1474,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
sshConn!,
|
||||
stream,
|
||||
lastJumpClient,
|
||||
opksshTempFiles,
|
||||
);
|
||||
sessionManager.attachWs(currentSessionId, userId, ws);
|
||||
|
||||
@@ -1352,9 +1494,47 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
|
||||
const boundSessionId = currentSessionId;
|
||||
|
||||
const CWD_SENTINEL = "TERMIX_CWD:";
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
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);
|
||||
if (session) {
|
||||
sessionManager.bufferOutput(boundSessionId!, utf8String);
|
||||
@@ -1385,15 +1565,24 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
stream.on("close", (code: number | null) => {
|
||||
const session = sessionManager.getSession(boundSessionId);
|
||||
if (session?.attachedWs?.readyState === WebSocket.OPEN) {
|
||||
session.attachedWs.send(
|
||||
JSON.stringify({
|
||||
type: "disconnected",
|
||||
message: "Connection lost",
|
||||
}),
|
||||
);
|
||||
if (code != null) {
|
||||
session.attachedWs.send(
|
||||
JSON.stringify({
|
||||
type: "session_ended",
|
||||
code,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
session.attachedWs.send(
|
||||
JSON.stringify({
|
||||
type: "disconnected",
|
||||
message: "Connection lost",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (boundSessionId) {
|
||||
sessionManager.destroySession(boundSessionId);
|
||||
@@ -1422,16 +1611,115 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
});
|
||||
|
||||
if (initialPath && initialPath.trim() !== "") {
|
||||
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
|
||||
stream.write(cdCommand);
|
||||
}
|
||||
const autoTmux = hostConfig.terminalConfig?.autoTmux === true;
|
||||
|
||||
if (executeCommand && executeCommand.trim() !== "") {
|
||||
// Helper to run initialPath/executeCommand after the shell
|
||||
// (or tmux session) is ready
|
||||
const runPostShellCommands = (delay: number) => {
|
||||
setTimeout(() => {
|
||||
const command = `${executeCommand}\n`;
|
||||
stream.write(command);
|
||||
}, 500);
|
||||
if (initialPath && initialPath.trim() !== "") {
|
||||
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\r`;
|
||||
stream.write(cdCommand);
|
||||
}
|
||||
if (executeCommand && executeCommand.trim() !== "") {
|
||||
setTimeout(() => {
|
||||
stream.write(`${executeCommand}\r`);
|
||||
}, 300);
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
|
||||
if (autoTmux && conn) {
|
||||
(async () => {
|
||||
try {
|
||||
const detection = await detectTmux(conn);
|
||||
if (!detection.available) {
|
||||
sshLogger.warn("tmux not found on remote host", {
|
||||
operation: "tmux_detection",
|
||||
hostId: id,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_unavailable",
|
||||
message:
|
||||
"tmux is not installed on the remote host. Falling back to standard shell.",
|
||||
}),
|
||||
);
|
||||
// tmux unavailable, run commands in plain shell
|
||||
runPostShellCommands(0);
|
||||
} else if (detection.sessions.length === 0) {
|
||||
attachOrCreateTmuxSession(stream);
|
||||
// Query the name tmux assigned after a short delay
|
||||
setTimeout(async () => {
|
||||
const sessionName = await queryNewestTmuxSession(conn);
|
||||
const session = sessionManager.getSession(boundSessionId);
|
||||
if (session) {
|
||||
session.tmuxSessionName = sessionName;
|
||||
}
|
||||
sshLogger.info("Created new tmux session", {
|
||||
operation: "tmux_new_session",
|
||||
sessionName,
|
||||
hostId: id,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_session_created",
|
||||
sessionName,
|
||||
}),
|
||||
);
|
||||
}, 500);
|
||||
// Wait for tmux to start before running commands inside it
|
||||
runPostShellCommands(500);
|
||||
} else if (detection.sessions.length === 1) {
|
||||
attachOrCreateTmuxSession(stream, detection.sessions[0].name);
|
||||
const sessionName = detection.sessions[0].name;
|
||||
const session = sessionManager.getSession(boundSessionId);
|
||||
if (session) {
|
||||
session.tmuxSessionName = sessionName;
|
||||
}
|
||||
sshLogger.info("Auto-attached to existing tmux session", {
|
||||
operation: "tmux_auto_attach",
|
||||
sessionName,
|
||||
hostId: id,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_session_attached",
|
||||
sessionName,
|
||||
}),
|
||||
);
|
||||
// Reattaching to existing session -- don't re-run
|
||||
// initialPath/executeCommand since the session already
|
||||
// has its own state
|
||||
} else {
|
||||
sshLogger.info(
|
||||
"Multiple tmux sessions found, sending list to frontend",
|
||||
{
|
||||
operation: "tmux_sessions_available",
|
||||
sessions: detection.sessions,
|
||||
hostId: id,
|
||||
},
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_sessions_available",
|
||||
sessions: detection.sessions,
|
||||
}),
|
||||
);
|
||||
// Commands deferred until user picks a session
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.error("tmux detection failed", error, {
|
||||
operation: "tmux_detection_error",
|
||||
hostId: id,
|
||||
});
|
||||
// Fallback: run commands in plain shell
|
||||
runPostShellCommands(0);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
// No tmux -- run commands directly as before
|
||||
runPostShellCommands(0);
|
||||
}
|
||||
|
||||
ws.send(
|
||||
@@ -1554,6 +1842,31 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
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 (
|
||||
authMethodNotAvailable &&
|
||||
resolvedCredentials.authType === "none" &&
|
||||
@@ -1727,7 +2040,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else if (!sshStream) {
|
||||
} else {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -1801,7 +2114,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
host: ip,
|
||||
port,
|
||||
username,
|
||||
tryKeyboard: true,
|
||||
tryKeyboard: resolvedCredentials.authType !== "none",
|
||||
keepaliveInterval:
|
||||
typeof hostKeepaliveInterval === "number"
|
||||
? hostKeepaliveInterval
|
||||
@@ -1859,18 +2172,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: [
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
"aes256-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
cipher: SSH_ALGORITHMS.cipher,
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
@@ -1927,6 +2229,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
if (resolvedCredentials.keyPassword) {
|
||||
connectConfig.passphrase = resolvedCredentials.keyPassword;
|
||||
}
|
||||
|
||||
if (resolvedCredentials.password) {
|
||||
connectConfig.password = resolvedCredentials.password;
|
||||
}
|
||||
} catch (keyError) {
|
||||
sshLogger.error("SSH key format error: " + keyError.message);
|
||||
ws.send(
|
||||
@@ -1974,19 +2280,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
|
||||
sendLog("auth", "info", "Using cached OPKSSH certificate");
|
||||
|
||||
const { promises: fs } = await import("fs");
|
||||
const path = await import("path");
|
||||
const os = await import("os");
|
||||
|
||||
const tempDir = os.tmpdir();
|
||||
const keyPath = path.join(tempDir, `opkssh-${userId}-${id}`);
|
||||
const certPath = `${keyPath}-cert.pub`;
|
||||
|
||||
await fs.writeFile(keyPath, token.privateKey, { mode: 0o600 });
|
||||
await fs.writeFile(certPath, token.sshCert, { mode: 0o600 });
|
||||
|
||||
opksshTempFiles = { keyPath, certPath };
|
||||
connectConfig.privateKey = await fs.readFile(keyPath);
|
||||
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
|
||||
await setupOPKSSHCertAuth(connectConfig, sshConn, token, username);
|
||||
} catch (opksshError) {
|
||||
sshLogger.error("OPKSSH authentication error", opksshError, {
|
||||
operation: "opkssh_auth_error",
|
||||
@@ -2017,6 +2312,24 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
hostConfig.portKnockSequence &&
|
||||
hostConfig.portKnockSequence.length > 0
|
||||
) {
|
||||
try {
|
||||
sshLogger.info(
|
||||
`Port knocking ${hostConfig.ip} (${hostConfig.portKnockSequence.length} ports)`,
|
||||
{ operation: "port_knock", hostId: hostConfig.id },
|
||||
);
|
||||
await performPortKnocking(hostConfig.ip, hostConfig.portKnockSequence);
|
||||
} catch {
|
||||
sshLogger.warn("Port knocking failed, attempting connection anyway", {
|
||||
operation: "port_knock",
|
||||
hostId: hostConfig.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const proxyConfig: SOCKS5Config | null =
|
||||
hostConfig.useSocks5 &&
|
||||
(hostConfig.socks5Host ||
|
||||
@@ -2171,6 +2484,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
} else {
|
||||
sendLog("handshake", "info", "Starting SSH session");
|
||||
sendLog("auth", "info", `Authenticating as ${username}`);
|
||||
|
||||
sshLogger.info("Initiating SSH connection", {
|
||||
operation: "terminal_ssh_connect_attempt",
|
||||
sessionId,
|
||||
@@ -2219,7 +2533,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
sshStream = null;
|
||||
sshConn = null;
|
||||
lastJumpClient = null;
|
||||
opksshTempFiles = null;
|
||||
|
||||
resetConnectionState();
|
||||
isCleaningUp = false;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { Client, ClientChannel } from "ssh2";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
|
||||
export interface TmuxSessionInfo {
|
||||
name: string;
|
||||
created: number;
|
||||
lastActivity: number;
|
||||
windows: number;
|
||||
attachedClients: number;
|
||||
}
|
||||
|
||||
export interface TmuxDetectionResult {
|
||||
available: boolean;
|
||||
sessions: TmuxSessionInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command on the remote host via a separate exec channel.
|
||||
* Returns stdout as a string. Does not pollute the interactive shell.
|
||||
*/
|
||||
export function execCommand(conn: Client, command: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
conn.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
stream.on("data", (data: Buffer) => {
|
||||
stdout += data.toString("utf-8");
|
||||
});
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString("utf-8");
|
||||
});
|
||||
stream.on("error", (err: Error) => {
|
||||
reject(err);
|
||||
});
|
||||
stream.on("close", (code: number) => {
|
||||
if (code !== 0 && stdout === "") {
|
||||
reject(
|
||||
new Error(stderr.trim() || `Command exited with code ${code}`),
|
||||
);
|
||||
} else {
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether tmux is installed and list all existing sessions with details.
|
||||
*/
|
||||
export async function detectTmux(conn: Client): Promise<TmuxDetectionResult> {
|
||||
try {
|
||||
await execCommand(conn, "command -v tmux");
|
||||
} catch {
|
||||
return { available: false, sessions: [] };
|
||||
}
|
||||
|
||||
let sessions: TmuxSessionInfo[] = [];
|
||||
try {
|
||||
const output = await execCommand(
|
||||
conn,
|
||||
`tmux list-sessions -F "#{session_name}|#{session_created}|#{session_activity}|#{session_windows}|#{session_attached}" 2>/dev/null`,
|
||||
);
|
||||
if (output) {
|
||||
sessions = output
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => {
|
||||
const [name, created, activity, windows, attached] = line.split("|");
|
||||
return {
|
||||
name,
|
||||
created: parseInt(created, 10) || 0,
|
||||
lastActivity: parseInt(activity, 10) || 0,
|
||||
windows: parseInt(windows, 10) || 0,
|
||||
attachedClients: parseInt(attached, 10) || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// tmux server not running yet -- no sessions exist
|
||||
}
|
||||
|
||||
return { available: true, sessions };
|
||||
}
|
||||
|
||||
// tmux options applied on every attach/create:
|
||||
// - mouse on: enables mouse wheel / touch scrollback through tmux history
|
||||
// - history-limit: deep scrollback buffer on the remote host
|
||||
// - set-clipboard on: use OSC 52 to sync tmux selections to the client clipboard
|
||||
// - mode-keys vi: use vi-style keys in copy mode
|
||||
// - MouseDragEnd: stop the selection but keep it highlighted so the user can
|
||||
// adjust and press Enter to copy (or drag again)
|
||||
// - Enter: copy the (possibly adjusted) selection and exit copy mode
|
||||
// - pane-mode-changed hook: on copy-mode entry, show a brief hint so users
|
||||
// know to press Enter to copy the selection
|
||||
// Using -q on set/set-hook to suppress errors on older tmux versions that don't support
|
||||
// a particular option (e.g. set-clipboard on tmux < 2.5). Note: set-hook doesn't support -q.
|
||||
const TMUX_OPTS =
|
||||
`set -gq mouse on` +
|
||||
` \\; set -gq history-limit 50000` +
|
||||
` \\; set -gq set-clipboard on` +
|
||||
` \\; set -gq mode-keys vi` +
|
||||
` \\; bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X stop-selection` +
|
||||
` \\; bind-key -T copy-mode-vi Enter send-keys -X copy-selection-and-cancel` +
|
||||
` \\; set-hook -g pane-mode-changed` +
|
||||
` 'if -F "#{pane_in_mode}"` +
|
||||
` "display-message -d 2500 \\"Adjust selection and press Enter to copy\\""'`;
|
||||
|
||||
/**
|
||||
* Write tmux attach or new-session command to the interactive shell stream.
|
||||
* Uses && exit so the shell only closes if tmux started successfully.
|
||||
*/
|
||||
export function attachOrCreateTmuxSession(
|
||||
stream: ClientChannel,
|
||||
existingSessionName?: string,
|
||||
): void {
|
||||
let command: string;
|
||||
if (existingSessionName) {
|
||||
command = `tmux ${TMUX_OPTS} \\; attach-session -t ${shellEscape(existingSessionName)} && exit\r`;
|
||||
} else {
|
||||
command = `tmux ${TMUX_OPTS} \\; new-session && exit\r`;
|
||||
}
|
||||
|
||||
sshLogger.info("Writing tmux command to shell", {
|
||||
operation: "tmux_attach_or_create",
|
||||
sessionName: existingSessionName || "(auto)",
|
||||
isReattach: !!existingSessionName,
|
||||
});
|
||||
|
||||
stream.write(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the name of the most recently created tmux session via exec channel.
|
||||
*/
|
||||
export async function queryNewestTmuxSession(
|
||||
conn: Client,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const output = await execCommand(
|
||||
conn,
|
||||
`tmux list-sessions -F "#{session_created}:#{session_name}" 2>/dev/null | sort -rn | head -1 | cut -d: -f2-`,
|
||||
);
|
||||
return output || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function shellEscape(s: string): string {
|
||||
return "'" + s.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
+1339
-409
File diff suppressed because it is too large
Load Diff
@@ -79,7 +79,6 @@ export async function collectCpuMetrics(client: Client): Promise<{
|
||||
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
|
||||
} catch {
|
||||
cpuPercent = null;
|
||||
cores = null;
|
||||
loadTriplet = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,10 +50,10 @@ export async function collectLoginStats(client: Client): Promise<LoginStats> {
|
||||
try {
|
||||
const date = new Date(timeStr);
|
||||
parsedTime = isNaN(date.getTime())
|
||||
? new Date().toISOString()
|
||||
? timeStr || "unknown"
|
||||
: date.toISOString();
|
||||
} catch {
|
||||
parsedTime = new Date().toISOString();
|
||||
parsedTime = timeStr || "unknown";
|
||||
}
|
||||
|
||||
recentLogins.push({
|
||||
@@ -99,21 +99,30 @@ export async function collectLoginStats(client: Client): Promise<LoginStats> {
|
||||
ip = ipMatch[1];
|
||||
}
|
||||
|
||||
const dateMatch = line.match(/^(\w+\s+\d+\s+\d+:\d+:\d+)/);
|
||||
const dateMatch = line.match(/^(\w+)\s+(\d+)\s+(\d+:\d+:\d+)/);
|
||||
if (dateMatch) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
timeStr = `${currentYear} ${dateMatch[1]}`;
|
||||
const [, month, day, time] = dateMatch;
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const candidate = new Date(`${month} ${day}, ${currentYear} ${time}`);
|
||||
if (!isNaN(candidate.getTime()) && candidate > now) {
|
||||
// If parsed date is in the future, it's from last year
|
||||
timeStr = `${month} ${day}, ${currentYear - 1} ${time}`;
|
||||
} else {
|
||||
timeStr = `${month} ${day}, ${currentYear} ${time}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (user && ip) {
|
||||
let parsedTime: string;
|
||||
try {
|
||||
const date = timeStr ? new Date(timeStr) : new Date();
|
||||
parsedTime = isNaN(date.getTime())
|
||||
? new Date().toISOString()
|
||||
: date.toISOString();
|
||||
const date = timeStr ? new Date(timeStr) : null;
|
||||
parsedTime =
|
||||
date && !isNaN(date.getTime())
|
||||
? date.toISOString()
|
||||
: timeStr || "unknown";
|
||||
} catch {
|
||||
parsedTime = new Date().toISOString();
|
||||
parsedTime = timeStr || "unknown";
|
||||
}
|
||||
|
||||
failedLogins.push({
|
||||
|
||||
+52
-34
@@ -7,7 +7,11 @@ import { AutoSSLSetup } from "./utils/auto-ssl-setup.js";
|
||||
import { AuthManager } from "./utils/auth-manager.js";
|
||||
import { DataCrypto } from "./utils/data-crypto.js";
|
||||
import { SystemCrypto } from "./utils/system-crypto.js";
|
||||
import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
import {
|
||||
systemLogger,
|
||||
versionLogger,
|
||||
setGlobalLogLevel,
|
||||
} from "./utils/logger.js";
|
||||
|
||||
(async () => {
|
||||
const initStartTime = Date.now();
|
||||
@@ -94,6 +98,7 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
await systemCrypto.initializeJWTSecret();
|
||||
await systemCrypto.initializeDatabaseKey();
|
||||
await systemCrypto.initializeEncryptionKey();
|
||||
await systemCrypto.initializeInternalAuthToken();
|
||||
|
||||
await AutoSSLSetup.initialize();
|
||||
@@ -112,28 +117,27 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
await authManager.initialize();
|
||||
DataCrypto.initialize();
|
||||
|
||||
const { OPKSSHBinaryManager } =
|
||||
await import("./utils/opkssh-binary-manager.js");
|
||||
try {
|
||||
await OPKSSHBinaryManager.ensureBinary();
|
||||
} catch (error) {
|
||||
const dataDir =
|
||||
process.env.DATA_DIR || path.join(process.cwd(), "db", "data");
|
||||
systemLogger.warn(
|
||||
"Failed to initialize OPKSSH binary - OPKSSH authentication will not be available",
|
||||
{
|
||||
operation: "opkssh_binary_init_failed",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
dataDir,
|
||||
},
|
||||
);
|
||||
}
|
||||
import("./utils/opkssh-binary-manager.js").then(
|
||||
({ OPKSSHBinaryManager }) => {
|
||||
OPKSSHBinaryManager.ensureBinary().catch((error) => {
|
||||
const dataDir =
|
||||
process.env.DATA_DIR || path.join(process.cwd(), "db", "data");
|
||||
systemLogger.warn(
|
||||
"Failed to initialize OPKSSH binary - OPKSSH authentication will not be available",
|
||||
{
|
||||
operation: "opkssh_binary_init_failed",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
dataDir,
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await import("./database/database.js");
|
||||
|
||||
await import("./ssh/terminal.js");
|
||||
await import("./ssh/tunnel.js");
|
||||
await import("./ssh/file-manager.js");
|
||||
@@ -142,6 +146,19 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
await import("./ssh/docker-console.js");
|
||||
await import("./dashboard.js");
|
||||
|
||||
// Initialize log level from database settings
|
||||
const { getDb: getDbForSettings } = await import("./database/db/index.js");
|
||||
const settingsDb = getDbForSettings();
|
||||
const logLevelRow = settingsDb.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'log_level'")
|
||||
.get() as { value: string } | undefined;
|
||||
if (logLevelRow) {
|
||||
setGlobalLogLevel(logLevelRow.value);
|
||||
systemLogger.info(`Log level set to: ${logLevelRow.value}`, {
|
||||
operation: "log_level_init",
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize Guacamole server for RDP/VNC/Telnet support
|
||||
const { getDb: getDbForGuac } = await import("./database/db/index.js");
|
||||
const guacDb = getDbForGuac();
|
||||
@@ -153,20 +170,21 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
: true;
|
||||
|
||||
if (process.env.ENABLE_GUACAMOLE !== "false" && guacEnabled) {
|
||||
try {
|
||||
await import("./guacamole/guacamole-server.js");
|
||||
systemLogger.info("Guacamole server initialized", {
|
||||
operation: "guac_init",
|
||||
import("./guacamole/guacamole-server.js")
|
||||
.then(() => {
|
||||
systemLogger.info("Guacamole server initialized", {
|
||||
operation: "guac_init",
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
systemLogger.warn(
|
||||
"Failed to initialize Guacamole server (guacd may not be available)",
|
||||
{
|
||||
operation: "guac_init_skip",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
systemLogger.warn(
|
||||
"Failed to initialize Guacamole server (guacd may not be available)",
|
||||
{
|
||||
operation: "guac_init_skip",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
systemLogger.success("Termix backend started successfully", {
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import swaggerJSDoc from "swagger-jsdoc";
|
||||
import swaggerJSDoc from "@deadendjs/swagger-jsdoc";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { promises as fs } from "fs";
|
||||
import { systemLogger } from "./utils/logger.js";
|
||||
|
||||
interface SwaggerOptions {
|
||||
definition: object;
|
||||
apis: string[];
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const projectRoot = path.join(__dirname, "..", "..", "..");
|
||||
|
||||
const swaggerOptions: swaggerJSDoc.Options = {
|
||||
const swaggerOptions: SwaggerOptions = {
|
||||
definition: {
|
||||
openapi: "3.0.3",
|
||||
info: {
|
||||
@@ -130,7 +135,7 @@ async function generateOpenAPISpec() {
|
||||
operation: "openapi_generate_start",
|
||||
});
|
||||
|
||||
const swaggerSpec = swaggerJSDoc(swaggerOptions);
|
||||
const swaggerSpec = await swaggerJSDoc(swaggerOptions);
|
||||
|
||||
const outputPath = path.join(projectRoot, "openapi.json");
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import crypto from "crypto";
|
||||
import { UserCrypto } from "./user-crypto.js";
|
||||
import { SystemCrypto } from "./system-crypto.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { databaseLogger, authLogger } from "./logger.js";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { db } from "../database/db/index.js";
|
||||
import { sessions, trustedDevices } from "../database/db/schema.js";
|
||||
import { sessions, trustedDevices, apiKeys } from "../database/db/schema.js";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { DeviceType } from "./user-agent-parser.js";
|
||||
@@ -25,12 +27,21 @@ interface JWTPayload {
|
||||
userId: string;
|
||||
sessionId?: string;
|
||||
pendingTOTP?: boolean;
|
||||
dataKeyWrap?: WrappedDataKey;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
interface WrappedDataKey {
|
||||
version: "v1";
|
||||
iv: string;
|
||||
tag: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface AuthenticatedRequest extends Request {
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
pendingTOTP?: boolean;
|
||||
dataKey?: Buffer;
|
||||
}
|
||||
@@ -194,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(
|
||||
userId: string,
|
||||
options: {
|
||||
@@ -206,15 +324,20 @@ class AuthManager {
|
||||
): Promise<string> {
|
||||
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
||||
|
||||
const timeoutRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
|
||||
.get() as { value: string } | undefined;
|
||||
const defaultExpiry = `${timeoutRow ? parseInt(timeoutRow.value, 10) || 24 : 24}h`;
|
||||
|
||||
let expiresIn = options.expiresIn;
|
||||
if (!expiresIn && !options.pendingTOTP) {
|
||||
if (options.rememberMe) {
|
||||
expiresIn = "30d";
|
||||
} else {
|
||||
expiresIn = "24h";
|
||||
expiresIn = defaultExpiry;
|
||||
}
|
||||
} else if (!expiresIn) {
|
||||
expiresIn = "24h";
|
||||
expiresIn = defaultExpiry;
|
||||
}
|
||||
|
||||
const payload: JWTPayload = { userId };
|
||||
@@ -225,6 +348,7 @@ class AuthManager {
|
||||
if (!options.pendingTOTP && options.deviceType && options.deviceInfo) {
|
||||
const sessionId = nanoid();
|
||||
payload.sessionId = sessionId;
|
||||
await this.addWrappedDataKey(payload);
|
||||
|
||||
const token = jwt.sign(payload, jwtSecret, {
|
||||
expiresIn,
|
||||
@@ -272,6 +396,7 @@ class AuthManager {
|
||||
return token;
|
||||
}
|
||||
|
||||
await this.addWrappedDataKey(payload);
|
||||
return jwt.sign(payload, jwtSecret, { expiresIn } as jwt.SignOptions);
|
||||
}
|
||||
|
||||
@@ -318,6 +443,11 @@ class AuthManager {
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.restoreDataKeyFromPayload(
|
||||
payload,
|
||||
sessionRecords[0].expiresAt,
|
||||
);
|
||||
} catch (dbError) {
|
||||
databaseLogger.error(
|
||||
"Failed to check session in database during JWT verification",
|
||||
@@ -329,6 +459,8 @@ class AuthManager {
|
||||
);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
await this.restoreDataKeyFromPayload(payload);
|
||||
}
|
||||
return payload;
|
||||
} catch (error) {
|
||||
@@ -341,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 {
|
||||
// expected - no-op, JWT tokens are stateless
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
invalidateUserTokens(_userId: string): void {
|
||||
// expected - no-op, handled by session management
|
||||
}
|
||||
@@ -528,9 +711,9 @@ class AuthManager {
|
||||
maxAge: number = 24 * 60 * 60 * 1000,
|
||||
) {
|
||||
return {
|
||||
httpOnly: false,
|
||||
httpOnly: true,
|
||||
secure: req.secure || req.headers["x-forwarded-proto"] === "https",
|
||||
sameSite: "strict" as const,
|
||||
sameSite: "lax" as const,
|
||||
maxAge: maxAge,
|
||||
path: "/",
|
||||
};
|
||||
@@ -538,13 +721,88 @@ class AuthManager {
|
||||
|
||||
getClearCookieOptions(req: RequestWithHeaders) {
|
||||
return {
|
||||
httpOnly: false,
|
||||
httpOnly: true,
|
||||
secure: req.secure || req.headers["x-forwarded-proto"] === "https",
|
||||
sameSite: "strict" as const,
|
||||
sameSite: "lax" as const,
|
||||
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() {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
@@ -561,10 +819,24 @@ class AuthManager {
|
||||
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);
|
||||
|
||||
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) {
|
||||
return res.status(401).json({
|
||||
error: "TOTP verification required",
|
||||
code: "TOTP_REQUIRED",
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.sessionId) {
|
||||
@@ -581,10 +853,13 @@ class AuthManager {
|
||||
sessionId: payload.sessionId,
|
||||
userId: payload.userId,
|
||||
});
|
||||
return res.status(401).json({
|
||||
error: "Session not found",
|
||||
code: "SESSION_NOT_FOUND",
|
||||
});
|
||||
return res
|
||||
.clearCookie("jwt", this.getClearCookieOptions(req))
|
||||
.status(401)
|
||||
.json({
|
||||
error: "Session not found",
|
||||
code: "SESSION_NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
const session = sessionRecords[0];
|
||||
@@ -641,10 +916,13 @@ class AuthManager {
|
||||
);
|
||||
});
|
||||
|
||||
return res.status(401).json({
|
||||
error: "Session has expired",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
return res
|
||||
.clearCookie("jwt", this.getClearCookieOptions(req))
|
||||
.status(401)
|
||||
.json({
|
||||
error: "Session has expired",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
db.update(sessions)
|
||||
@@ -668,6 +946,7 @@ class AuthManager {
|
||||
}
|
||||
|
||||
authReq.userId = payload.userId;
|
||||
authReq.sessionId = payload.sessionId;
|
||||
authReq.pendingTOTP = payload.pendingTOTP;
|
||||
next();
|
||||
};
|
||||
@@ -702,10 +981,30 @@ class AuthManager {
|
||||
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);
|
||||
|
||||
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) {
|
||||
return res.status(401).json({
|
||||
error: "TOTP verification required",
|
||||
code: "TOTP_REQUIRED",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -732,6 +1031,7 @@ class AuthManager {
|
||||
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
authReq.userId = payload.userId;
|
||||
authReq.sessionId = payload.sessionId;
|
||||
authReq.pendingTOTP = payload.pendingTOTP;
|
||||
next();
|
||||
} catch (error) {
|
||||
@@ -785,6 +1085,22 @@ class AuthManager {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await db.delete(sessions).where(eq(sessions.userId, userId));
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to revoke all sessions on logout", error, {
|
||||
operation: "session_revoke_all_failed",
|
||||
userId,
|
||||
});
|
||||
}
|
||||
this.userCrypto.logoutUser(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +171,7 @@ IP.3 = 0.0.0.0
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`SSL certificate generation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import cors from "cors";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { getRequestOrigin } from "./request-origin.js";
|
||||
|
||||
const DEV_ORIGINS = ["http://localhost:5173", "http://127.0.0.1:5173"];
|
||||
const ELECTRON_FILE_ORIGIN = "file://";
|
||||
|
||||
function getAllowedOrigins(): string[] {
|
||||
const envOrigins = process.env.CORS_ALLOWED_ORIGINS;
|
||||
if (!envOrigins) return [];
|
||||
return envOrigins
|
||||
.split(",")
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function isLocalRequest(req: Request): boolean {
|
||||
const remoteAddr = req.socket?.remoteAddress || req.ip || "";
|
||||
return (
|
||||
remoteAddr === "127.0.0.1" ||
|
||||
remoteAddr === "::1" ||
|
||||
remoteAddr === "::ffff:127.0.0.1"
|
||||
);
|
||||
}
|
||||
|
||||
export function createCorsMiddleware(
|
||||
methods: string[] = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
extraHeaders: string[] = [],
|
||||
) {
|
||||
const allowedHeaders = [
|
||||
"Origin",
|
||||
"X-Requested-With",
|
||||
"Content-Type",
|
||||
"Accept",
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"X-Electron-App",
|
||||
"Cache-Control",
|
||||
...extraHeaders,
|
||||
];
|
||||
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const handler = cors({
|
||||
origin: (origin, callback) => {
|
||||
// No origin = same-origin or non-browser request (curl, internal service calls)
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
// Requests coming from localhost (nginx proxy, internal service calls)
|
||||
if (isLocalRequest(req)) return callback(null, true);
|
||||
|
||||
if (DEV_ORIGINS.includes(origin)) return callback(null, true);
|
||||
if (origin.startsWith(ELECTRON_FILE_ORIGIN))
|
||||
return callback(null, true);
|
||||
|
||||
const configured = getAllowedOrigins();
|
||||
if (configured.includes("*") || configured.includes(origin))
|
||||
return callback(null, true);
|
||||
|
||||
const sameOrigin = getRequestOrigin(req);
|
||||
if (origin === sameOrigin) return callback(null, true);
|
||||
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
methods,
|
||||
allowedHeaders,
|
||||
});
|
||||
handler(req, res, next);
|
||||
};
|
||||
}
|
||||
@@ -107,24 +107,24 @@ export class CredentialSystemEncryptionMigration {
|
||||
|
||||
migrated++;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to migrate credential", error, {
|
||||
credentialId: cred.id,
|
||||
userId,
|
||||
});
|
||||
databaseLogger.warn(
|
||||
`Skipping credential migration for credential ${cred.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
{
|
||||
operation: "credential_migration_skip",
|
||||
credentialId: cred.id,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
return { migrated, failed, skipped };
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Credential system encryption migration failed",
|
||||
error,
|
||||
{
|
||||
operation: "credential_migration_failed",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
);
|
||||
databaseLogger.warn("Credential system encryption migration incomplete", {
|
||||
operation: "credential_migration_incomplete",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ class DatabaseFileEncryption {
|
||||
});
|
||||
throw new Error(
|
||||
`Database buffer encryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -197,6 +198,7 @@ class DatabaseFileEncryption {
|
||||
});
|
||||
throw new Error(
|
||||
`Database file encryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -237,6 +239,7 @@ class DatabaseFileEncryption {
|
||||
if (!fs.existsSync(metadataPath)) {
|
||||
throw new Error(
|
||||
`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) {
|
||||
throw new Error(
|
||||
`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` +
|
||||
`- DATABASE_KEY in environment: ${!!process.env.DATABASE_KEY}\n` +
|
||||
`Original error: ${errorMessage}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -366,7 +371,9 @@ class DatabaseFileEncryption {
|
||||
encryptedPath,
|
||||
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(
|
||||
`Database file decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,8 +50,8 @@ export class DatabaseMigration {
|
||||
}
|
||||
}
|
||||
|
||||
let needsMigration = false;
|
||||
let reason = "";
|
||||
let needsMigration: boolean;
|
||||
let reason: string;
|
||||
|
||||
if (hasEncryptedDb && hasUnencryptedDb) {
|
||||
const unencryptedSize = fs.statSync(this.unencryptedDbPath).size;
|
||||
@@ -119,6 +119,7 @@ export class DatabaseMigration {
|
||||
});
|
||||
throw new Error(
|
||||
`Backup creation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,35 @@
|
||||
import chalk from "chalk";
|
||||
import type { ChalkInstance } from "chalk";
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error" | "success";
|
||||
|
||||
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
success: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
let globalLogLevel: LogLevel = "info";
|
||||
|
||||
export function setGlobalLogLevel(level: string): void {
|
||||
const normalized = level.toLowerCase();
|
||||
if (normalized in LOG_LEVEL_PRIORITY) {
|
||||
globalLogLevel = normalized as LogLevel;
|
||||
}
|
||||
}
|
||||
|
||||
export function getGlobalLogLevel(): LogLevel {
|
||||
return globalLogLevel;
|
||||
}
|
||||
|
||||
// Initialize from environment variable if set
|
||||
const envLogLevel = process.env.LOG_LEVEL?.toLowerCase();
|
||||
if (envLogLevel && envLogLevel in LOG_LEVEL_PRIORITY) {
|
||||
globalLogLevel = envLogLevel as LogLevel;
|
||||
}
|
||||
|
||||
export interface LogContext {
|
||||
service?: string;
|
||||
operation?: string;
|
||||
@@ -51,7 +79,17 @@ export class Logger {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -122,7 +160,7 @@ export class Logger {
|
||||
return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`;
|
||||
}
|
||||
|
||||
private getLevelColor(level: LogLevel): chalk.Chalk {
|
||||
private getLevelColor(level: LogLevel): ChalkInstance {
|
||||
switch (level) {
|
||||
case "debug":
|
||||
return chalk.magenta;
|
||||
@@ -140,7 +178,7 @@ export class Logger {
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel, message: string): boolean {
|
||||
if (level === "debug" && process.env.NODE_ENV === "production") {
|
||||
if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[globalLogLevel]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ export class OPKSSHBinaryManager {
|
||||
const osMap: Record<string, string> = {
|
||||
win32: "windows",
|
||||
linux: "linux",
|
||||
darwin: "darwin",
|
||||
darwin: "osx",
|
||||
};
|
||||
|
||||
const archMap: Record<string, string> = {
|
||||
@@ -209,7 +209,7 @@ export class OPKSSHBinaryManager {
|
||||
const osMap: Record<string, string> = {
|
||||
win32: "windows",
|
||||
linux: "linux",
|
||||
darwin: "darwin",
|
||||
darwin: "osx",
|
||||
};
|
||||
|
||||
const archMap: Record<string, string> = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import type { Agent } from "http";
|
||||
import { ProxyAgent } from "undici";
|
||||
import type { Dispatcher } from "undici-types";
|
||||
|
||||
export function getProxyAgent(targetUrl?: string): Agent | undefined {
|
||||
export function getProxyAgent(targetUrl?: string): Dispatcher | undefined {
|
||||
const proxyUrl =
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ export function getRequestOrigin(req: Request | IncomingMessage): string {
|
||||
const protoHeader = req.headers["x-forwarded-proto"];
|
||||
|
||||
if (protoHeader) {
|
||||
protocol =
|
||||
const raw =
|
||||
typeof protoHeader === "string"
|
||||
? protoHeader.split(",")[0].trim()
|
||||
: protoHeader[0];
|
||||
// Normalize WebSocket protocols to their HTTP equivalents
|
||||
protocol = raw === "wss" ? "https" : raw === "ws" ? "http" : raw;
|
||||
} else if ("protocol" in req && req.protocol) {
|
||||
protocol = req.protocol;
|
||||
} else {
|
||||
|
||||
@@ -379,7 +379,7 @@ class SharedCredentialManager {
|
||||
cred.keyPassword,
|
||||
ownerDEK,
|
||||
credentialId,
|
||||
"key_password",
|
||||
"keyPassword",
|
||||
)
|
||||
: undefined,
|
||||
keyType: cred.keyType,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import crypto from "crypto";
|
||||
import type { ConnectConfig, CipherAlgorithm } from "ssh2";
|
||||
|
||||
// Maps SSH cipher names to their OpenSSL equivalents (as used by ssh2 internally)
|
||||
const SSH_CIPHER_SSL_NAME: Partial<Record<CipherAlgorithm, string>> = {
|
||||
"chacha20-poly1305@openssh.com": "chacha20",
|
||||
"aes256-gcm@openssh.com": "aes-256-gcm",
|
||||
"aes128-gcm@openssh.com": "aes-128-gcm",
|
||||
"aes256-ctr": "aes-256-ctr",
|
||||
"aes192-ctr": "aes-192-ctr",
|
||||
"aes128-ctr": "aes-128-ctr",
|
||||
"aes256-cbc": "aes-256-cbc",
|
||||
"aes192-cbc": "aes-192-cbc",
|
||||
"aes128-cbc": "aes-128-cbc",
|
||||
"3des-cbc": "des-ede3-cbc",
|
||||
};
|
||||
|
||||
const availableCiphers = new Set(crypto.getCiphers());
|
||||
|
||||
function filterCiphers(list: CipherAlgorithm[]): CipherAlgorithm[] {
|
||||
return list.filter((name) => {
|
||||
const sslName = SSH_CIPHER_SSL_NAME[name];
|
||||
return !sslName || availableCiphers.has(sslName);
|
||||
});
|
||||
}
|
||||
|
||||
export const SSH_ALGORITHMS: NonNullable<ConnectConfig["algorithms"]> = {
|
||||
kex: [
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp521",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
],
|
||||
serverHostKey: [
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"rsa-sha2-512",
|
||||
"rsa-sha2-256",
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: filterCiphers([
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
"aes256-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-cbc",
|
||||
"3des-cbc",
|
||||
]),
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
};
|
||||
@@ -7,6 +7,7 @@ class SystemCrypto {
|
||||
private static instance: SystemCrypto;
|
||||
private jwtSecret: string | null = null;
|
||||
private databaseKey: Buffer | null = null;
|
||||
private encryptionKey: Buffer | null = null;
|
||||
private internalAuthToken: string | null = null;
|
||||
private credentialSharingKey: Buffer | null = null;
|
||||
|
||||
@@ -61,7 +62,7 @@ class SystemCrypto {
|
||||
databaseLogger.error("Failed to initialize JWT secret", error, {
|
||||
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",
|
||||
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!;
|
||||
}
|
||||
|
||||
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> {
|
||||
try {
|
||||
const envToken = process.env.INTERNAL_AUTH_TOKEN;
|
||||
@@ -142,7 +183,9 @@ class SystemCrypto {
|
||||
databaseLogger.error("Failed to initialize internal auth token", error, {
|
||||
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",
|
||||
},
|
||||
);
|
||||
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> {
|
||||
const newToken = crypto.randomBytes(32).toString("hex");
|
||||
const instanceId = crypto.randomBytes(8).toString("hex");
|
||||
|
||||
@@ -255,16 +255,12 @@ function parseMacVersion(userAgent: string): string {
|
||||
* Ignores minor version numbers to handle browser auto-updates.
|
||||
*/
|
||||
export function generateDeviceFingerprint(deviceInfo: DeviceInfo): string {
|
||||
let fingerprintString = "";
|
||||
|
||||
if (deviceInfo.type === "desktop") {
|
||||
fingerprintString = `${deviceInfo.type}|${deviceInfo.browser}|${deviceInfo.os}`;
|
||||
} else if (deviceInfo.type === "mobile") {
|
||||
fingerprintString = `${deviceInfo.type}|${deviceInfo.browser}|${deviceInfo.os}`;
|
||||
} else {
|
||||
const browserMajor = deviceInfo.version.split(".")[0];
|
||||
fingerprintString = `${deviceInfo.type}|${deviceInfo.browser} ${browserMajor}|${deviceInfo.os}`;
|
||||
}
|
||||
const fingerprintString =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? `${deviceInfo.type}|${deviceInfo.browser}|${deviceInfo.os}`
|
||||
: `${deviceInfo.type}|${deviceInfo.browser} ${
|
||||
deviceInfo.version.split(".")[0]
|
||||
}|${deviceInfo.os}`;
|
||||
|
||||
return crypto.createHash("sha256").update(fingerprintString).digest("hex");
|
||||
}
|
||||
|
||||
@@ -265,6 +265,19 @@ class UserCrypto {
|
||||
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 {
|
||||
const session = this.userSessions.get(userId);
|
||||
if (session) {
|
||||
|
||||
@@ -482,7 +482,7 @@ class UserDataImport {
|
||||
return await this.importUserData(targetUserId, exportData, options);
|
||||
} catch (error) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import dgram from "dgram";
|
||||
|
||||
const MAC_REGEX = /^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/;
|
||||
|
||||
function parseMac(mac: string): Buffer {
|
||||
return Buffer.from(mac.replace(/[:-]/g, ""), "hex");
|
||||
}
|
||||
|
||||
function buildMagicPacket(mac: string): Buffer {
|
||||
const macBytes = parseMac(mac);
|
||||
const packet = Buffer.alloc(102);
|
||||
packet.fill(0xff, 0, 6);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
macBytes.copy(packet, 6 + i * 6);
|
||||
}
|
||||
return packet;
|
||||
}
|
||||
|
||||
export function isValidMac(mac: string): boolean {
|
||||
return MAC_REGEX.test(mac);
|
||||
}
|
||||
|
||||
export function sendWakeOnLan(mac: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!isValidMac(mac)) {
|
||||
return reject(new Error("Invalid MAC address"));
|
||||
}
|
||||
|
||||
const packet = buildMagicPacket(mac);
|
||||
const socket = dgram.createSocket("udp4");
|
||||
|
||||
socket.once("error", (err) => {
|
||||
socket.close();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
socket.send(packet, 0, packet.length, 9, "255.255.255.255", (err) => {
|
||||
socket.close();
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "dark" | "light" | "system";
|
||||
type Theme =
|
||||
| "dark"
|
||||
| "light"
|
||||
| "system"
|
||||
| "dracula"
|
||||
| "gentlemansChoice"
|
||||
| "midnightEspresso"
|
||||
| "catppuccinMocha";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -12,11 +19,13 @@ type ThemeProviderProps = {
|
||||
type ThemeProviderState = {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
setThemePreview: (theme: Theme | null) => void;
|
||||
};
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
setThemePreview: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
@@ -30,13 +39,23 @@ export function ThemeProvider({
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
|
||||
);
|
||||
const [previewTheme, setPreviewTheme] = useState<Theme | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.remove(
|
||||
"light",
|
||||
"dark",
|
||||
"dracula",
|
||||
"gentlemansChoice",
|
||||
"midnightEspresso",
|
||||
"catppuccinMocha",
|
||||
);
|
||||
|
||||
if (theme === "system") {
|
||||
const activeTheme = previewTheme || theme;
|
||||
|
||||
if (activeTheme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
@@ -46,8 +65,18 @@ export function ThemeProvider({
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
root.classList.add(activeTheme);
|
||||
|
||||
const darkCustomThemes: Theme[] = [
|
||||
"dracula",
|
||||
"gentlemansChoice",
|
||||
"midnightEspresso",
|
||||
"catppuccinMocha",
|
||||
];
|
||||
if (darkCustomThemes.includes(activeTheme)) {
|
||||
root.classList.add("dark");
|
||||
}
|
||||
}, [theme, previewTheme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
@@ -55,6 +84,9 @@ export function ThemeProvider({
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
setThemePreview: (theme: Theme | null) => {
|
||||
setPreviewTheme(theme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -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;
|
||||
+55
-24
@@ -1,28 +1,59 @@
|
||||
import { type ComponentProps, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
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",
|
||||
"[&_svg:not([class*='size-'])]:size-3",
|
||||
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export type KbdProps = ComponentProps<"span"> & {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd-group"
|
||||
className={cn("inline-flex items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export const Kbd = ({ className, children, ...props }: KbdProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex select-none items-center rounded-md border px-2 py-1 text-[10px] font-mono font-medium relative",
|
||||
"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)]",
|
||||
"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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export { Kbd, KbdGroup };
|
||||
export type KbdKeyProps = ComponentProps<"span"> & {
|
||||
"aria-label"?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
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}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import * as React from "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";
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
|
||||
}: React.ComponentProps<typeof ResizableGroup>) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
<ResizableGroup
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
@@ -22,19 +26,19 @@ function ResizablePanelGroup({
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||
}: React.ComponentProps<typeof ResizablePrimitivePanel>) {
|
||||
return <ResizablePrimitivePanel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
}: React.ComponentProps<typeof ResizableSeparator> & {
|
||||
withHandle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
<ResizableSeparator
|
||||
data-slot="resizable-handle"
|
||||
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",
|
||||
@@ -47,7 +51,7 @@ function ResizableHandle({
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
</ResizableSeparator>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -598,9 +598,9 @@ function SidebarMenuSkeleton({
|
||||
showIcon?: boolean;
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
const [width] = React.useState(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, []);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -39,9 +39,19 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
message: rateLimitedToast,
|
||||
});
|
||||
|
||||
const darkCustomThemes = [
|
||||
"dracula",
|
||||
"gentlemansChoice",
|
||||
"midnightEspresso",
|
||||
"catppuccinMocha",
|
||||
];
|
||||
const sonnerTheme: ToasterProps["theme"] = darkCustomThemes.includes(theme)
|
||||
? "dark"
|
||||
: (theme as ToasterProps["theme"]);
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
theme={sonnerTheme}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from "react";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.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";
|
||||
|
||||
interface VersionAlertProps {
|
||||
updateInfo: {
|
||||
success: boolean;
|
||||
status?: "up_to_date" | "requires_update";
|
||||
status?: "up_to_date" | "requires_update" | "beta";
|
||||
localVersion?: string;
|
||||
remoteVersion?: string;
|
||||
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") {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
|
||||
@@ -671,6 +671,62 @@ export const TERMINAL_THEMES: Record<string, TerminalTheme> = {
|
||||
brightWhite: "#a6adc8",
|
||||
},
|
||||
},
|
||||
|
||||
gentlemansChoice: {
|
||||
name: "Gentleman's Choice",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#1a1c1a",
|
||||
foreground: "#d1c7a3",
|
||||
cursor: "#d1c7a3",
|
||||
cursorAccent: "#1a1c1a",
|
||||
selectionBackground: "#3e4437",
|
||||
black: "#1a1c1a",
|
||||
red: "#9d3a3a",
|
||||
green: "#5a7a3a",
|
||||
yellow: "#b39a3a",
|
||||
blue: "#3a5a7a",
|
||||
magenta: "#7a3a5a",
|
||||
cyan: "#3a7a7a",
|
||||
white: "#d1c7a3",
|
||||
brightBlack: "#3e4437",
|
||||
brightRed: "#bf4a4a",
|
||||
brightGreen: "#7a9a4a",
|
||||
brightYellow: "#d1b34a",
|
||||
brightBlue: "#4a7abf",
|
||||
brightMagenta: "#9a4abf",
|
||||
brightCyan: "#4abfbf",
|
||||
brightWhite: "#e3dbc3",
|
||||
},
|
||||
},
|
||||
|
||||
midnightEspresso: {
|
||||
name: "Midnight Espresso",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#120f0d",
|
||||
foreground: "#ceb195",
|
||||
cursor: "#ceb195",
|
||||
cursorAccent: "#120f0d",
|
||||
selectionBackground: "#3d2b1f",
|
||||
black: "#120f0d",
|
||||
red: "#a05a4a",
|
||||
green: "#7a8a5a",
|
||||
yellow: "#b08a4a",
|
||||
blue: "#5a7a9a",
|
||||
magenta: "#8a5a7a",
|
||||
cyan: "#5a8a8a",
|
||||
white: "#ceb195",
|
||||
brightBlack: "#3d2b1f",
|
||||
brightRed: "#c07a6a",
|
||||
brightGreen: "#9aaa7a",
|
||||
brightYellow: "#d0aa6a",
|
||||
brightBlue: "#7a9aba",
|
||||
brightMagenta: "#aa7aba",
|
||||
brightCyan: "#7ababa",
|
||||
brightWhite: "#e0cbb5",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TERMINAL_FONTS = [
|
||||
@@ -764,6 +820,7 @@ export const DEFAULT_TERMINAL_CONFIG = {
|
||||
sudoPasswordAutoFill: false,
|
||||
keepaliveInterval: undefined as number | undefined,
|
||||
keepaliveCountMax: undefined as number | undefined,
|
||||
autoTmux: false,
|
||||
};
|
||||
|
||||
export type TerminalConfigType = typeof DEFAULT_TERMINAL_CONFIG;
|
||||
|
||||
@@ -144,7 +144,7 @@ export function useConfirmation() {
|
||||
setPendingConfirmCallback(null);
|
||||
setPendingResolve(null);
|
||||
},
|
||||
} as any);
|
||||
} as NonNullable<Parameters<typeof toast>[1]>);
|
||||
|
||||
if (confirmOnEnter) {
|
||||
setActiveToastId(toastId);
|
||||
|
||||
@@ -3,8 +3,9 @@ import * as React from "react";
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
undefined,
|
||||
const [isMobile, setIsMobile] = React.useState<boolean>(
|
||||
() =>
|
||||
typeof window !== "undefined" && window.innerWidth < MOBILE_BREAKPOINT,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -13,9 +14,8 @@ export function useIsMobile() {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { isElectron } from "@/ui/main-axios";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
|
||||
interface ServiceWorkerState {
|
||||
@@ -40,27 +40,54 @@ export function useServiceWorker(): ServiceWorkerState {
|
||||
|
||||
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 () => {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register(
|
||||
`${getBasePath()}/sw.js`,
|
||||
{ updateViaCache: "none" },
|
||||
);
|
||||
setState((prev) => ({ ...prev, isRegistered: true }));
|
||||
|
||||
registration.addEventListener("updatefound", () =>
|
||||
handleUpdateFound(registration),
|
||||
);
|
||||
await registration.update();
|
||||
} catch (error) {
|
||||
console.error("[SW] Registration failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
navigator.serviceWorker.addEventListener(
|
||||
"controllerchange",
|
||||
handleControllerChange,
|
||||
);
|
||||
|
||||
if (document.readyState === "complete") {
|
||||
registerSW();
|
||||
} else {
|
||||
window.addEventListener("load", registerSW);
|
||||
return () => window.removeEventListener("load", registerSW);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("load", registerSW);
|
||||
navigator.serviceWorker.removeEventListener(
|
||||
"controllerchange",
|
||||
handleControllerChange,
|
||||
);
|
||||
};
|
||||
}, [handleUpdateFound]);
|
||||
|
||||
return state;
|
||||
|
||||
+71
-174
@@ -1,84 +1,82 @@
|
||||
import i18n from "i18next";
|
||||
import i18n, { type BackendModule, type ResourceKey } from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
|
||||
import enTranslation from "../locales/en.json";
|
||||
import afTranslation from "../locales/translated/af_ZA.json";
|
||||
import arTranslation from "../locales/translated/ar_SA.json";
|
||||
import bnTranslation from "../locales/translated/bn_BD.json";
|
||||
import bgTranslation from "../locales/translated/bg_BG.json";
|
||||
import caTranslation from "../locales/translated/ca_ES.json";
|
||||
import csTranslation from "../locales/translated/cs_CZ.json";
|
||||
import daTranslation from "../locales/translated/da_DK.json";
|
||||
import deTranslation from "../locales/translated/de_DE.json";
|
||||
import elTranslation from "../locales/translated/el_GR.json";
|
||||
import esESTranslation from "../locales/translated/es_ES.json";
|
||||
import fiTranslation from "../locales/translated/fi_FI.json";
|
||||
import frTranslation from "../locales/translated/fr_FR.json";
|
||||
import heTranslation from "../locales/translated/he_IL.json";
|
||||
import hiTranslation from "../locales/translated/hi_IN.json";
|
||||
import huTranslation from "../locales/translated/hu_HU.json";
|
||||
import idTranslation from "../locales/translated/id_ID.json";
|
||||
import itTranslation from "../locales/translated/it_IT.json";
|
||||
import jaTranslation from "../locales/translated/ja_JP.json";
|
||||
import koTranslation from "../locales/translated/ko_KR.json";
|
||||
import nlTranslation from "../locales/translated/nl_NL.json";
|
||||
import noTranslation from "../locales/translated/no_NO.json";
|
||||
import plTranslation from "../locales/translated/pl_PL.json";
|
||||
import ptPTTranslation from "../locales/translated/pt_PT.json";
|
||||
import ptBRTranslation from "../locales/translated/pt_BR.json";
|
||||
import roTranslation from "../locales/translated/ro_RO.json";
|
||||
import ruTranslation from "../locales/translated/ru_RU.json";
|
||||
import srTranslation from "../locales/translated/sr_SP.json";
|
||||
import svSETranslation from "../locales/translated/sv_SE.json";
|
||||
import thTranslation from "../locales/translated/th_TH.json";
|
||||
import trTranslation from "../locales/translated/tr_TR.json";
|
||||
import ukTranslation from "../locales/translated/uk_UA.json";
|
||||
import viTranslation from "../locales/translated/vi_VN.json";
|
||||
import zhCNTranslation from "../locales/translated/zh_CN.json";
|
||||
import zhTWTranslation from "../locales/translated/zh_TW.json";
|
||||
|
||||
type LocaleModule = { default: ResourceKey };
|
||||
|
||||
const localeLoaders = {
|
||||
af: () => import("../locales/translated/af_ZA.json"),
|
||||
ar: () => import("../locales/translated/ar_SA.json"),
|
||||
bn: () => import("../locales/translated/bn_BD.json"),
|
||||
bg: () => import("../locales/translated/bg_BG.json"),
|
||||
ca: () => import("../locales/translated/ca_ES.json"),
|
||||
cs: () => import("../locales/translated/cs_CZ.json"),
|
||||
da: () => import("../locales/translated/da_DK.json"),
|
||||
de: () => import("../locales/translated/de_DE.json"),
|
||||
el: () => import("../locales/translated/el_GR.json"),
|
||||
"es-ES": () => import("../locales/translated/es_ES.json"),
|
||||
fi: () => import("../locales/translated/fi_FI.json"),
|
||||
fr: () => import("../locales/translated/fr_FR.json"),
|
||||
he: () => import("../locales/translated/he_IL.json"),
|
||||
hi: () => import("../locales/translated/hi_IN.json"),
|
||||
hu: () => import("../locales/translated/hu_HU.json"),
|
||||
id: () => import("../locales/translated/id_ID.json"),
|
||||
it: () => import("../locales/translated/it_IT.json"),
|
||||
ja: () => import("../locales/translated/ja_JP.json"),
|
||||
ko: () => import("../locales/translated/ko_KR.json"),
|
||||
nl: () => import("../locales/translated/nl_NL.json"),
|
||||
no: () => import("../locales/translated/no_NO.json"),
|
||||
pl: () => import("../locales/translated/pl_PL.json"),
|
||||
"pt-PT": () => import("../locales/translated/pt_PT.json"),
|
||||
"pt-BR": () => import("../locales/translated/pt_BR.json"),
|
||||
ro: () => import("../locales/translated/ro_RO.json"),
|
||||
ru: () => import("../locales/translated/ru_RU.json"),
|
||||
sr: () => import("../locales/translated/sr_SP.json"),
|
||||
"sv-SE": () => import("../locales/translated/sv_SE.json"),
|
||||
th: () => import("../locales/translated/th_TH.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
|
||||
.use(localeBackend)
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
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",
|
||||
],
|
||||
supportedLngs,
|
||||
fallbackLng: "en",
|
||||
debug: false,
|
||||
|
||||
@@ -94,109 +92,8 @@ i18n
|
||||
en: {
|
||||
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: {
|
||||
escapeValue: false,
|
||||
|
||||
+264
@@ -233,6 +233,266 @@
|
||||
--bg-overlay: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.dracula {
|
||||
--background: #282a36;
|
||||
--foreground: #ffffff;
|
||||
--card: #343746;
|
||||
--card-foreground: #ffffff;
|
||||
--popover: #343746;
|
||||
--popover-foreground: #ffffff;
|
||||
--primary: #ffffff;
|
||||
--primary-foreground: #282a36;
|
||||
--secondary: #44475a;
|
||||
--secondary-foreground: #ffffff;
|
||||
--muted: #343746;
|
||||
--muted-foreground: #a6accd;
|
||||
--accent: #44475a;
|
||||
--accent-foreground: #ffffff;
|
||||
--destructive: #ff5555;
|
||||
--border: #44475a;
|
||||
--input: #343746;
|
||||
--ring: #8be9fd;
|
||||
--chart-1: #8be9fd;
|
||||
--chart-2: #50fa7b;
|
||||
--chart-3: #ffb86c;
|
||||
--chart-4: #bd93f9;
|
||||
--chart-5: #ff79c6;
|
||||
--sidebar: #1e1f29;
|
||||
--sidebar-foreground: #ffffff;
|
||||
--sidebar-primary: #8be9fd;
|
||||
--sidebar-primary-foreground: #1e1f29;
|
||||
--sidebar-accent: #44475a;
|
||||
--sidebar-accent-foreground: #ffffff;
|
||||
--sidebar-border: #44475a;
|
||||
--sidebar-ring: #8be9fd;
|
||||
|
||||
--bg-base: #282a36;
|
||||
--bg-elevated: #343746;
|
||||
--bg-surface: #343746;
|
||||
--bg-surface-hover: #44475a;
|
||||
--bg-input: #343746;
|
||||
--bg-deepest: #1e1f29;
|
||||
--bg-header: #1e1f29;
|
||||
--bg-button: #343746;
|
||||
--bg-active: #44475a;
|
||||
--bg-light: #343746;
|
||||
--bg-subtle: #1e1f29;
|
||||
--bg-interact: #44475a;
|
||||
--border-base: #44475a;
|
||||
--border-panel: #1e1f29;
|
||||
--border-subtle: #44475a;
|
||||
--border-medium: #44475a;
|
||||
--bg-hover: #44475a;
|
||||
--bg-hover-alt: #44475a;
|
||||
--bg-pressed: #1e1f29;
|
||||
--border-hover: #6272a4;
|
||||
--border-active: #8be9fd;
|
||||
|
||||
--foreground-secondary: #a6accd;
|
||||
--foreground-subtle: #6272a4;
|
||||
|
||||
--scrollbar-thumb: #44475a;
|
||||
--scrollbar-thumb-hover: #6272a4;
|
||||
--scrollbar-track: #282a36;
|
||||
|
||||
--bg-overlay: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.gentlemansChoice {
|
||||
--background: #1a1c1a;
|
||||
--foreground: #d1c7a3;
|
||||
--card: #2a2c2a;
|
||||
--card-foreground: #d1c7a3;
|
||||
--popover: #2a2c2a;
|
||||
--popover-foreground: #d1c7a3;
|
||||
--primary: #d1c7a3;
|
||||
--primary-foreground: #1a1c1a;
|
||||
--secondary: #3e4437;
|
||||
--secondary-foreground: #d1c7a3;
|
||||
--muted: #2a2c2a;
|
||||
--muted-foreground: #8e8463;
|
||||
--accent: #3e4437;
|
||||
--accent-foreground: #d1c7a3;
|
||||
--destructive: #9d3a3a;
|
||||
--border: #3e4437;
|
||||
--input: #2a2c2a;
|
||||
--ring: #5a7a3a;
|
||||
--chart-1: #5a7a3a;
|
||||
--chart-2: #3a5a7a;
|
||||
--chart-3: #b39a3a;
|
||||
--chart-4: #7a3a5a;
|
||||
--chart-5: #3a7a7a;
|
||||
--sidebar: #151715;
|
||||
--sidebar-foreground: #d1c7a3;
|
||||
--sidebar-primary: #5a7a3a;
|
||||
--sidebar-primary-foreground: #151715;
|
||||
--sidebar-accent: #3e4437;
|
||||
--sidebar-accent-foreground: #d1c7a3;
|
||||
--sidebar-border: #3e4437;
|
||||
--sidebar-ring: #5a7a3a;
|
||||
|
||||
--bg-base: #1a1c1a;
|
||||
--bg-elevated: #2a2c2a;
|
||||
--bg-surface: #2a2c2a;
|
||||
--bg-surface-hover: #3e4437;
|
||||
--bg-input: #2a2c2a;
|
||||
--bg-deepest: #151715;
|
||||
--bg-header: #151715;
|
||||
--bg-button: #2a2c2a;
|
||||
--bg-active: #3e4437;
|
||||
--bg-light: #2a2c2a;
|
||||
--bg-subtle: #151715;
|
||||
--bg-interact: #3e4437;
|
||||
--border-base: #3e4437;
|
||||
--border-panel: #151715;
|
||||
--border-subtle: #3e4437;
|
||||
--border-medium: #3e4437;
|
||||
--bg-hover: #3e4437;
|
||||
--bg-hover-alt: #3e4437;
|
||||
--bg-pressed: #151715;
|
||||
--border-hover: #5a7a3a;
|
||||
--border-active: #d1c7a3;
|
||||
|
||||
--foreground-secondary: #8e8463;
|
||||
--foreground-subtle: #5e5841;
|
||||
|
||||
--scrollbar-thumb: #3e4437;
|
||||
--scrollbar-thumb-hover: #5e5841;
|
||||
--scrollbar-track: #1a1c1a;
|
||||
|
||||
--bg-overlay: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.midnightEspresso {
|
||||
--background: #120f0d;
|
||||
--foreground: #ceb195;
|
||||
--card: #1f1a17;
|
||||
--card-foreground: #ceb195;
|
||||
--popover: #1f1a17;
|
||||
--popover-foreground: #ceb195;
|
||||
--primary: #ceb195;
|
||||
--primary-foreground: #120f0d;
|
||||
--secondary: #3d2b1f;
|
||||
--secondary-foreground: #ceb195;
|
||||
--muted: #1f1a17;
|
||||
--muted-foreground: #9a8a7a;
|
||||
--accent: #3d2b1f;
|
||||
--accent-foreground: #ceb195;
|
||||
--destructive: #a05a4a;
|
||||
--border: #3d2b1f;
|
||||
--input: #1f1a17;
|
||||
--ring: #7a8a5a;
|
||||
--chart-1: #7a8a5a;
|
||||
--chart-2: #5a7a9a;
|
||||
--chart-3: #b08a4a;
|
||||
--chart-4: #8a5a7a;
|
||||
--chart-5: #5a8a8a;
|
||||
--sidebar: #0d0b0a;
|
||||
--sidebar-foreground: #ceb195;
|
||||
--sidebar-primary: #7a8a5a;
|
||||
--sidebar-primary-foreground: #0d0b0a;
|
||||
--sidebar-accent: #3d2b1f;
|
||||
--sidebar-accent-foreground: #ceb195;
|
||||
--sidebar-border: #3d2b1f;
|
||||
--sidebar-ring: #7a8a5a;
|
||||
|
||||
--bg-base: #120f0d;
|
||||
--bg-elevated: #1f1a17;
|
||||
--bg-surface: #1f1a17;
|
||||
--bg-surface-hover: #3d2b1f;
|
||||
--bg-input: #1f1a17;
|
||||
--bg-deepest: #0d0b0a;
|
||||
--bg-header: #0d0b0a;
|
||||
--bg-button: #1f1a17;
|
||||
--bg-active: #3d2b1f;
|
||||
--bg-light: #1f1a17;
|
||||
--bg-subtle: #0d0b0a;
|
||||
--bg-interact: #3d2b1f;
|
||||
--border-base: #3d2b1f;
|
||||
--border-panel: #0d0b0a;
|
||||
--border-subtle: #3d2b1f;
|
||||
--border-medium: #3d2b1f;
|
||||
--bg-hover: #3d2b1f;
|
||||
--bg-hover-alt: #3d2b1f;
|
||||
--bg-pressed: #0d0b0a;
|
||||
--border-hover: #7a8a5a;
|
||||
--border-active: #ceb195;
|
||||
|
||||
--foreground-secondary: #9a8a7a;
|
||||
--foreground-subtle: #6a5a4a;
|
||||
|
||||
--scrollbar-thumb: #3d2b1f;
|
||||
--scrollbar-thumb-hover: #6a5a4a;
|
||||
--scrollbar-track: #120f0d;
|
||||
|
||||
--bg-overlay: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.catppuccinMocha {
|
||||
--background: #1e1e2e;
|
||||
--foreground: #cdd6f4;
|
||||
--card: #181825;
|
||||
--card-foreground: #cdd6f4;
|
||||
--popover: #181825;
|
||||
--popover-foreground: #cdd6f4;
|
||||
--primary: #cdd6f4;
|
||||
--primary-foreground: #1e1e2e;
|
||||
--secondary: #313244;
|
||||
--secondary-foreground: #cdd6f4;
|
||||
--muted: #181825;
|
||||
--muted-foreground: #a6adc8;
|
||||
--accent: #313244;
|
||||
--accent-foreground: #cdd6f4;
|
||||
--destructive: #f38ba8;
|
||||
--border: #313244;
|
||||
--input: #181825;
|
||||
--ring: #89b4fa;
|
||||
--chart-1: #89b4fa;
|
||||
--chart-2: #a6e3a1;
|
||||
--chart-3: #f9e2af;
|
||||
--chart-4: #f5c2e7;
|
||||
--chart-5: #94e2d5;
|
||||
--sidebar: #11111b;
|
||||
--sidebar-foreground: #cdd6f4;
|
||||
--sidebar-primary: #89b4fa;
|
||||
--sidebar-primary-foreground: #11111b;
|
||||
--sidebar-accent: #313244;
|
||||
--sidebar-accent-foreground: #cdd6f4;
|
||||
--sidebar-border: #313244;
|
||||
--sidebar-ring: #89b4fa;
|
||||
|
||||
--bg-base: #1e1e2e;
|
||||
--bg-elevated: #181825;
|
||||
--bg-surface: #181825;
|
||||
--bg-surface-hover: #313244;
|
||||
--bg-input: #181825;
|
||||
--bg-deepest: #11111b;
|
||||
--bg-header: #11111b;
|
||||
--bg-button: #181825;
|
||||
--bg-active: #313244;
|
||||
--bg-light: #181825;
|
||||
--bg-subtle: #11111b;
|
||||
--bg-interact: #313244;
|
||||
--border-base: #313244;
|
||||
--border-panel: #11111b;
|
||||
--border-subtle: #313244;
|
||||
--border-medium: #313244;
|
||||
--bg-hover: #313244;
|
||||
--bg-hover-alt: #313244;
|
||||
--bg-pressed: #11111b;
|
||||
--border-hover: #89b4fa;
|
||||
--border-active: #cdd6f4;
|
||||
|
||||
--foreground-secondary: #a6adc8;
|
||||
--foreground-subtle: #7f849c;
|
||||
|
||||
--scrollbar-thumb: #313244;
|
||||
--scrollbar-thumb-hover: #45475a;
|
||||
--scrollbar-track: #1e1e2e;
|
||||
|
||||
--bg-overlay: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body {
|
||||
@@ -293,3 +553,7 @@
|
||||
.skinny-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
.h-fade {
|
||||
mask-image: linear-gradient(transparent, #000 6%, #000 94%, transparent);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user