-**Docker Management:**
-Start, stop, pause, remove containers. View container stats. Control container using docker exec terminal. It was not made to replace Portainer or Dockge but rather to simply manage your containers compared to creating them.
+**Docker and Podman Management:**
+Start, stop, pause, remove containers. View container stats. Control containers using a docker exec terminal. Supports both Docker and Podman as the container runtime. It was not made to replace Portainer or Dockge but rather to simply manage your containers compared to creating them.
@@ -103,13 +109,13 @@ Save, organize, and manage your SSH connections with tags and folders (folder cu
**Host Metrics:**
-View CPU, memory, disk usage, network, uptime, system information, firewall, port monitor, log viewer, users/permissions, certificates, and many more which work on most Linux based servers.
+View CPU, memory, disk usage, network, uptime, system information, firewall, port monitor, log viewer, users/permissions, certificates, and many more which work on most Linux based servers. Includes time-series history graphs and threshold-based alerts with ntfy and webhook support.
**User Authentication:**
-Secure user management with admin controls and OIDC/LDAP/SSO (with access control) and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together. View audit log of all users actions.
+Secure user management with admin controls and OIDC/LDAP/SSO (with access control), 2FA (TOTP), and passkey (WebAuthn) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together. View audit log of all users actions.
@@ -130,32 +136,52 @@ Create roles and share hosts across users/roles.
+**Serial Connections:**
+Connect to serial devices (routers, switches, microcontrollers, etc.) directly from the browser or desktop app. Configure baud rate, data bits, stop bits, and parity. Uses the Web Serial API in supported browsers or a native backend in the Electron app.
+
+
+
+
+**Alerts:**
+Set threshold-based alert rules on host metrics (CPU, memory, disk, etc.) and get notified via ntfy or webhooks when they fire. View firing and resolved alerts in a history log.
+
+
+
+
+
+
+**Homepage:**
+A fully customizable homepage with a drag-and-drop widget grid. Add widgets for host status, service links, clocks, notes, RSS feeds, weather, Docker containers, host metrics charts, embedded terminals, iframes, and more.
+
+
+
+
**Database Encryption:**
Backend stored as encrypted SQLite database files. View [docs](https://docs.termix.site/security) for more.
+
+
**Network Graph:**
Customize your Dashboard to visualize your homelab based off your SSH connections with status support.
-
-
**SSH Tools:**
Create reusable command snippets that execute with a single click. Run one command simultaneously across multiple open terminals.
+
+
**Persistent Tabs:**
SSH sessions and tabs stay open across devices/refreshes if enabled in user profile.
-
-
**Languages:**
@@ -180,7 +206,8 @@ Built-in support ~30 languages (managed by [Crowdin](https://docs.termix.site/tr
- **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
- **Proxmox Integration** - Auto-add hosts into Termix from your Proxmox instance
-- **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, terminal logging, 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, terminal logging, SSH agent forwarding, Bitwarden SSH agent, HashiCorp Vault SSH signing, and more.
+- **Termix ID** - A sshid.io equivalent built into Termix. Claim a handle, publish your public SSH keys at a resolver URL, and use a built-in CA to issue SSH certificates.
@@ -314,6 +341,10 @@ Termix is free and open source with no subscriptions or paid plans. If you find
+
+
+
+
Some videos and images may be out of date or may not perfectly showcase features.
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 160a559e..2718dc5c 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,123 +1,81 @@
-Dozens of bug fixes and small new features, including VNC/RDP sharing, OIDC improvements, file manager fixes, SSH jump host fixes, terminal enhancements, RDP keyboard layout support, and much more.
+Major new features including serial connections, Tailscale/WireGuard support, HashiCorp Vault SSH auth, Bitwarden SSH agent, WebAuthn passkeys, Podman support, a new grid-based dashboard, host metrics history with alerting, and much more.
-https://youtu.be/ImwAbm4hW-k
+https://youtu.be/c3UD4q2jW_8
-- VNC, RDP, and Telnet credential sharing support
-- Default host settings (SOCKS5, credentials, terminal settings, and more)
-- Custom terminal background image per host
-- Silent OIDC login configurable as default (no URL parameter required)
-- Bulk open SSH sessions for multiple selected hosts at once
-- Improved Nord theme contrast and accessibility
-- Admin can manually create users while registration is disabled
-- OIDC auto-provisioned users supported when registration and password login are disabled
-- OIDC username usable as SSH username credential
-- Custom labels and drag-to-reorder for sessions in the Connections panel
-- Appearance and profile settings persisted to the database across devices
-- Saved server URL dropdown in the app connection screen
-- File manager text editor font size adjustment
-- Tab key shortcut for entering your username in the terminal
-- Shift+Tab hotkey support for mobile
-- Terminal keyboard shortcuts support
-- UTF-8 encoding support in the file manager
-- Optional broadcast address for Wake-on-LAN packets
-- SFTP legacy mode support
-- Snippets JSON import and export with folder metadata
-- Clickable links and service shortcuts on the dashboard
-- Host status color indicators restored to sidebar
-- Per-host configuration to set tab title to shell's window title instead of host name
-- Split screen hotkeys
-- Support for AZERTY and other non-QWERTY keyboard layouts in RDP
-- RDP load balancing info and Connection Broker Cookie support
-- Per-connection guacd proxy host and port configuration
-- Deep link support for bookmarking direct SSH or file manager sessions
-- ACME/Certbot SSL certificate support
-- Import hosts from SSH config file
-- OIDC with custom CA certificate support
-- Open files directly in the file manager text editor
-- File manager text editor Ctrl+W capture to prevent accidental tab close
-- Option to collapse hosts to a single line in the sidebar
-- Select a different backend Termix server from within the app
-- Windows portable app now truly portable (no registry writes)
-- Bundle fonts for offline environments
-- Option to disable clickable links in the terminal
-- Proxmox login options including OPKSSH
-- UI suggestions and general interface improvements
-- Per-protocol host metrics (online/offline detection) configuration
-- Increase file manager max upload size by splicing files and reassembling them (~5GB)
+- Termix ID with a public handle, hosted public key resolver, and built-in CA for issuing SSH certificates
+- Serial connections support
+- Tailscale and WireGuard VPN host integration with status detection
+- HashiCorp Vault SSH signer authentication
+- Bitwarden SSH agent integration
+- WebAuthn passkey authentication
+- Podman container runtime support alongside Docker
+- SSH agent forwarding support across all SSH features
+- New grid and widget-based dashboard homepage
+- Grafana-style server stats history graphs
+- Alert system with ntfy and webhook notification support
+- Host temperature metrics card
+- App fullscreen mode
+- External editor support for file manager (desktop app)
+- Safe host sharing export
+- SSH credential password fallback for key-based auth
+- Open all sessions in a folder at once
+- Custom terminal theme color support
+- Custom tunnel endpoints configuration
+- GUACD_URL environment variable support
+- App rail hover expansion setting
+- Terminal font zoom with mouse wheel
+- File manager terminals promoted to full tabs
+- Donate button on dashboard
+- PuTTY PPK SSH key support
+- Confirmation dialog when closing active host connections
+- Confirmation prompt before opening large files in the editor
+- Cross-host file manager clipboard
+- Prioritize host results in command palette search
+- Retry autostart tunnel host fetches on failure
-- File transfers over 100MB failing
-- File transfers over 30 seconds aborted by axios timeout
-- SSH terminal through jump host chain timing out with malformed SSH messages
-- Cloning a host with SSH key auth not allowing auth method change on the clone
-- File deletion in the file manager affecting selected files in inactive tabs
-- File manager not connecting when cert passphrase is not saved
-- File text editor cursor position lost on save
-- macOS Option + Left/Right Arrow outputting raw ANSI sequences instead of moving cursor by word
-- File manager tree view not sorted alphabetically
-- SFTP failing with timeout when using a jump host chain
-- SSH key auth with Duo not showing prompt and failing immediately
-- Enabling 2FA with password login disabled causing a login deadlock
-- Failed to disable 2FA even when providing correct TOTP or password
-- Arrow buttons not working in Midnight Commander on the Android app
-- Credential deploy command copy failing silently (clipboard API unavailable in some browsers)
-- Disabling password login still showing the password login form
-- Folder picker in the new host form not showing existing folders
-- Command palette always opening hosts as SSH terminal regardless of protocol settings
-- Saving SSH credentials failing on fresh installs
-- Import hosts failing when hosts use SSH key credentials
-- Warpgate authentication prompting for a password unnecessarily
-- File manager folder icon not appearing under host name on hover
-- File manager delete silently failing on Windows hosts (rm -f not supported by PowerShell)
-- SSH terminal failing with keepalive timeout when server MOTD is slow to load
-- SSH connection via SOCKS5 proxy failing after update
-- Linking an OIDC account to a password account not working
-- User password copy missing and RBAC issues in admin panel
-- Debian and Android packages not opening the server on launch
-- Username field in host edit and new host form having no effect
-- Mobile terminal crashing on iOS with React error 130
-- Network interface symbols incorrect in host metrics
-- Sudo password auto-fill not working on Ubuntu Server 26.04
-- get_cwd command injecting into live PTY and corrupting interactive programs
-- Initial directory command failing on Windows PowerShell SSH targets
-- 3-way split not working with layout issues
-- Docker management not working for Windows hosts
-- Docker management failing on Debian with exit code 1
-- Docker logs not respecting the current theme
-- API not responding correctly after update
-- Caddy reverse proxy configuration not working
-- Wrong keyboard layout in VNC sessions
-- KDE scaling issues in the desktop app
-- Linux app failing to start due to better-sqlite3 Node version mismatch
-- Tab autocomplete not working in the macOS x86 app
-- Cloudflare SSL tunnels with third-level subdomains blocking Termix web portal access
-- Fzf completion not working in the Android app
-- SSH terminal frame delivery jitter in Docker (ssh2 native crypto not compiled)
-- OIDC login failing in Linux and Android apps when Authentik has a Captcha stage
-- Android app crashing after entering password when auth is set to None
-- Editing or saving a host clearing the password for RDP and VNC connections
-- Hardcoded 30-minute open-tabs TTL defeating session persistence
-- Keyboard mapping issues on Windows Server 2019 via RDP
-- Shared server not appearing for other users
-- RBAC role assignment failing for OIDC users
-- SSO configuration broken when supplied via environment variables
-- RDP requiring credentials even when none are needed
-- Terminal outputting success right after folder path
-- GitHub/Google SSO provider giving ERR_INVALID_URL
-- Keyboard focus not on main screen when selecting tmux session
-- Remove all references to SALT variable
-- Syntax highlighter duplicating path, visual corruptions, cursor jumps, etc.
-- RDP session screen clips/spills over on viewport resize
+- SSH port connection bug
+- VNC required argument handshake failure
+- Jump host SOCKS5 proxy selection using wrong proxy
+- Tunnel endpoint resolution failing in some configurations
+- Direct tunnel skipping endpoint credential validation incorrectly
+- Dashboard host routing ignoring protocol settings
+- Dashboard service link creation broken
+- File manager uploads failing with 400 error and missing schema migrations on upgrade
+- Large file manager uploads not chunked (chunked for files >=1.5GB)
+- File uploads over 100MB failing due to ArrayBuffer browser limit
+- File path case not preserved in file manager UI
+- File downloads unreliable in the desktop app
+- Tmux detection path handling incorrect
+- Host metrics startup polling incorrect
+- TUI terminal output highlighting incorrect
+- Runtime base path for auth callbacks incorrect
+- Windows app icon unstable
+- SSH heading syntax highlighting broken
+- Terminal link dialog layering issue
+- Electron OIDC browser authentication failures
+- Proxmox import auth fallback not working
+- OIDC role credential shares not synced for OIDC users
+- RDP connections requiring credentials when none are needed
+- VNC authentication settings not persisted
+- Guacamole unicode token corruption
+- Guacamole websocket base path incorrect
+- Guacamole disconnect during startup crash
+- Host metrics starting for non-SSH hosts
+- Sidebar host hover causing layout shift
+- Alert UI incorrectly applying Termix CSS and alert system failing to load
+- Translation key incorrect for nav close action
+- PUID HTML ownership in Docker entrypoint
diff --git a/biome.json b/biome.json
new file mode 100644
index 00000000..68bf4687
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,58 @@
+{
+ "$schema": "https://biomejs.dev/schemas/2.5.1/schema.json",
+ "vcs": {
+ "enabled": true,
+ "clientKind": "git",
+ "useIgnoreFile": true,
+ "defaultBranch": "dev-2.5.0"
+ },
+ "files": {
+ "ignoreUnknown": true,
+ "includes": [
+ "**",
+ "!!build",
+ "!!coverage",
+ "!!dist",
+ "!!dist-ssr",
+ "!!release",
+ "!!node_modules",
+ "!!src/mcp-server/node_modules",
+ "!!db",
+ "!!.env",
+ "!!**/*.min.js",
+ "!!**/*.min.css",
+ "!!openapi.json"
+ ]
+ },
+ "formatter": {
+ "enabled": true,
+ "indentStyle": "space",
+ "indentWidth": 2,
+ "lineWidth": 80,
+ "lineEnding": "lf"
+ },
+ "linter": {
+ "enabled": false
+ },
+ "javascript": {
+ "formatter": {
+ "quoteStyle": "double",
+ "semicolons": "always",
+ "trailingCommas": "all",
+ "arrowParentheses": "always"
+ }
+ },
+ "json": {
+ "formatter": {
+ "trailingCommas": "none"
+ }
+ },
+ "css": {
+ "parser": {
+ "tailwindDirectives": true
+ }
+ },
+ "assist": {
+ "enabled": false
+ }
+}
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 5b2c28e1..a016afbd 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -1,5 +1,5 @@
# Stage 1: Install dependencies
-FROM node:26-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/*
@@ -36,7 +36,7 @@ RUN npm rebuild better-sqlite3
RUN npm run build:backend
# Stage 4: Production dependencies only
-FROM node:26-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/*
@@ -53,7 +53,7 @@ RUN npm ci --omit=dev --ignore-scripts && \
npm cache clean --force
# Stage 5: Final optimized image
-FROM node:26-slim
+FROM node:24-slim
WORKDIR /app
ENV DATA_DIR=/app/data \
@@ -78,7 +78,7 @@ COPY --chown=node:node package.json ./
VOLUME ["/app/data"]
-EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006
+EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006 30007 30008 30009 30010 30011 30012
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD wget -q -O /dev/null http://localhost:30001/health || exit 1
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index c46e28c2..917ea608 100644
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -14,7 +14,7 @@ if [ "$(id -u)" = "0" ]; then
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 /tmp/nginx 2>/dev/null || true
+ chown -R node:node /app/data /app/uploads /app/html /tmp/nginx 2>/dev/null || true
echo "User node is now UID: $PUID, GID: $PGID"
@@ -167,4 +167,4 @@ node dist/backend/backend/starter.js
echo "All services started"
-tail -f /dev/null
\ No newline at end of file
+tail -f /dev/null
diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf
index ee58e8fb..d2e38f5b 100644
--- a/docker/nginx-https.conf
+++ b/docker/nginx-https.conf
@@ -159,6 +159,33 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
+ location ~ ^/alert-rules(/.*)?$ {
+ 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 $proxy_x_forwarded_proto;
+ }
+
+ location ~ ^/notification-channels(/.*)?$ {
+ 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 $proxy_x_forwarded_proto;
+ }
+
+ location ~ ^/alert-firings(/.*)?$ {
+ 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 $proxy_x_forwarded_proto;
+ }
+
location ~ ^/rbac(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
@@ -190,6 +217,24 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
+ location ~ ^/vault(/.*)?$ {
+ 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 $proxy_x_forwarded_proto;
+ }
+
+ location ~ ^/termix-id(/.*)?$ {
+ 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 ~ ^/c2s-tunnel-presets(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
@@ -610,6 +655,15 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
+ location ~ ^/homepage(/.*)?$ {
+ proxy_pass http://127.0.0.1:30012;
+ 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 $proxy_x_forwarded_proto;
+ }
+
location ^~ /docker/console/ {
proxy_pass http://127.0.0.1:30009/;
proxy_http_version 1.1;
@@ -663,6 +717,27 @@ http {
proxy_read_timeout 300s;
}
+ location ^~ /serial/websocket/ {
+ proxy_pass http://127.0.0.1:30011/;
+ proxy_http_version 1.1;
+
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $http_host;
+ proxy_cache_bypass $http_upgrade;
+
+ 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 $proxy_x_forwarded_proto;
+
+ proxy_read_timeout 86400s;
+ proxy_send_timeout 86400s;
+ proxy_connect_timeout 10s;
+
+ proxy_buffering off;
+ proxy_request_buffering off;
+ }
+
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /app/html;
diff --git a/docker/nginx.conf b/docker/nginx.conf
index 5da83bcf..a8a44e2a 100644
--- a/docker/nginx.conf
+++ b/docker/nginx.conf
@@ -148,6 +148,33 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
+ location ~ ^/alert-rules(/.*)?$ {
+ 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 $proxy_x_forwarded_proto;
+ }
+
+ location ~ ^/notification-channels(/.*)?$ {
+ 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 $proxy_x_forwarded_proto;
+ }
+
+ location ~ ^/alert-firings(/.*)?$ {
+ 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 $proxy_x_forwarded_proto;
+ }
+
location ~ ^/rbac(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
@@ -179,6 +206,24 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
+ location ~ ^/vault(/.*)?$ {
+ 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 $proxy_x_forwarded_proto;
+ }
+
+ location ~ ^/termix-id(/.*)?$ {
+ 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 ~ ^/proxmox(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
@@ -611,6 +656,15 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
+ location ~ ^/homepage(/.*)?$ {
+ proxy_pass http://127.0.0.1:30012;
+ 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 $proxy_x_forwarded_proto;
+ }
+
location ^~ /docker/console/ {
proxy_pass http://127.0.0.1:30009/;
proxy_http_version 1.1;
@@ -664,6 +718,27 @@ http {
proxy_read_timeout 300s;
}
+ location ^~ /serial/websocket/ {
+ proxy_pass http://127.0.0.1:30011/;
+ proxy_http_version 1.1;
+
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $http_host;
+ proxy_cache_bypass $http_upgrade;
+
+ 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 $proxy_x_forwarded_proto;
+
+ proxy_read_timeout 86400s;
+ proxy_send_timeout 86400s;
+ proxy_connect_timeout 10s;
+
+ proxy_buffering off;
+ proxy_request_buffering off;
+ }
+
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /app/html;
diff --git a/electron-builder.json b/electron-builder.json
index d2eb0685..81106b0b 100644
--- a/electron-builder.json
+++ b/electron-builder.json
@@ -28,11 +28,8 @@
"!dist/icon-mac.png",
"!public/icon-mac.png",
"!dist/icon.ico",
- "!public/icon.ico",
"!dist/icon.icns",
- "!public/icon.icns",
- "!dist/icons/**/*",
- "!public/icons/**/*"
+ "!public/icon.icns"
],
"extraMetadata": {
"main": "electron/main.cjs",
@@ -61,6 +58,8 @@
"artifactName": "termix_windows_${arch}_nsis.${ext}",
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
+ "installerIcon": "public/icon.ico",
+ "uninstallerIcon": "public/icon.ico",
"shortcutName": "Termix",
"uninstallDisplayName": "Termix"
},
diff --git a/electron/main.cjs b/electron/main.cjs
index 75600256..2efe350f 100644
--- a/electron/main.cjs
+++ b/electron/main.cjs
@@ -9,6 +9,7 @@ const {
safeStorage,
Tray,
clipboard,
+ nativeImage,
} = require("electron");
const path = require("path");
const fs = require("fs");
@@ -17,7 +18,7 @@ const https = require("https");
const http = require("http");
const net = require("net");
const { URL } = require("url");
-const { fork } = require("child_process");
+const { fork, spawn } = require("child_process");
const WebSocket = require("ws");
// Portable mode: if a `.portable` marker exists next to the executable,
@@ -528,15 +529,117 @@ let backendProcess = null;
let backendStartFailed = false;
let tray = null;
let isQuitting = false;
+const tempFiles = new Map();
+const externalEditorSessions = new Map();
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
const appRoot = isDev ? process.cwd() : path.join(__dirname, "..");
+const windowsAppUserModelId = "com.karmaa.termix";
const electronCacheBuildPath = path.join(
app.getPath("userData"),
"client-cache-build.json",
);
const termixSessionPartition = "persist:termix";
+function getTempRoot() {
+ const tempRoot = path.join(app.getPath("temp"), "termix");
+ fs.mkdirSync(tempRoot, { recursive: true });
+ return tempRoot;
+}
+
+function sanitizeFileName(fileName) {
+ const baseName = path.basename(String(fileName || "file"));
+ return baseName.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_") || "file";
+}
+
+function decodeFileContent(content, encoding) {
+ if (encoding === "base64") {
+ return Buffer.from(String(content || ""), "base64");
+ }
+ return Buffer.from(String(content || ""), "utf8");
+}
+
+function createManagedTempFile(fileName, content, encoding = "utf8") {
+ const tempId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
+ const tempDir = fs.mkdtempSync(path.join(getTempRoot(), `${tempId}-`));
+ const filePath = path.join(tempDir, sanitizeFileName(fileName));
+ fs.writeFileSync(filePath, decodeFileContent(content, encoding));
+ tempFiles.set(tempId, { path: filePath, dir: tempDir });
+ return { tempId, path: filePath };
+}
+
+function cleanupManagedTempFile(tempId) {
+ const temp = tempFiles.get(tempId);
+ if (!temp) return;
+ try {
+ fs.rmSync(temp.dir, { recursive: true, force: true });
+ } catch (error) {
+ logToFile("Failed to clean up temporary file:", error.message);
+ }
+ tempFiles.delete(tempId);
+}
+
+function closeExternalEditorSession(editId) {
+ const session = externalEditorSessions.get(editId);
+ if (!session) return;
+ if (session.timer) clearTimeout(session.timer);
+ try {
+ session.watcher.close();
+ } catch {
+ // watcher may already be closed
+ }
+ externalEditorSessions.delete(editId);
+ cleanupManagedTempFile(editId);
+}
+
+function notifyExternalEditorSaved(editId) {
+ const session = externalEditorSessions.get(editId);
+ if (!session || !mainWindow || mainWindow.isDestroyed()) return;
+
+ try {
+ const stat = fs.statSync(session.path);
+ if (stat.mtimeMs === session.lastMtimeMs) return;
+ session.lastMtimeMs = stat.mtimeMs;
+
+ const content = fs.readFileSync(session.path, "utf8");
+ mainWindow.webContents.send("external-editor-saved", {
+ editId,
+ content,
+ encoding: "utf8",
+ path: session.path,
+ });
+ } catch (error) {
+ logToFile("Failed to read external editor file:", error.message);
+ }
+}
+
+function openPathWithEditor(filePath, editorPath) {
+ if (!editorPath) {
+ return shell.openPath(filePath);
+ }
+
+ return new Promise((resolve) => {
+ let settled = false;
+ const child = spawn(editorPath, [filePath], {
+ detached: true,
+ stdio: "ignore",
+ });
+
+ child.once("error", (error) => {
+ if (settled) return;
+ settled = true;
+ resolve(error.message);
+ });
+
+ setTimeout(() => {
+ if (settled) return;
+ settled = true;
+ child.unref();
+ resolve("");
+ }, 500);
+ });
+}
+
app.on(
"certificate-error",
(event, _webContents, url, error, certificate, callback) => {
@@ -690,9 +793,11 @@ function getBackendPaths() {
backendCwd: backendDir,
};
}
- // fork() does not go through Electron's asar redirector — use the unpacked path
+ // fork() does not go through Electron's asar redirector — use the unpacked path.
+ // On macOS multi-arch builds (mergeASARs: false), electron-builder names the ASAR
+ // app-arm64.asar / app-x64.asar instead of app.asar, so match all variants.
const unpackedRoot = appRoot.replace(
- /app\.asar(?!\.unpacked)/,
+ /app(-[a-z0-9]+)?\.asar(?!\.unpacked)/,
"app.asar.unpacked",
);
const backendDir = path.join(unpackedRoot, "dist", "backend", "backend");
@@ -850,7 +955,13 @@ function createTray() {
// use the unpacked path so the OS sees a real file.
const publicRoot = isDev
? path.join(appRoot, "public")
- : path.join(appRoot.replace("app.asar", "app.asar.unpacked"), "public");
+ : path.join(
+ appRoot.replace(
+ /app(-[a-z0-9]+)?\.asar(?!\.unpacked)/,
+ "app.asar.unpacked",
+ ),
+ "public",
+ );
let trayIcon;
if (process.platform === "darwin") {
@@ -920,7 +1031,11 @@ function createWindow() {
minWidth: 800,
minHeight: 600,
title: "Termix",
- icon: path.join(appRoot, "public", "icon.png"),
+ icon: path.join(
+ appRoot,
+ "public",
+ process.platform === "win32" ? "icon.ico" : "icon.png",
+ ),
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
@@ -1231,9 +1346,11 @@ ipcMain.handle(
async (_event, authUrl, callbackPort) => {
const http = require("http");
- return new Promise((resolve, reject) => {
+ return new Promise((resolve) => {
+ let timeout;
+ let settled = false;
const server = http.createServer((req, res) => {
- const url = new URL(req.url, `http://localhost:${callbackPort}`);
+ const url = new URL(req.url || "/", `http://localhost:${callbackPort}`);
if (url.pathname === "/oidc-callback") {
const success = url.searchParams.get("success");
const error = url.searchParams.get("error");
@@ -1244,27 +1361,54 @@ ipcMain.handle(
`
-**إدارة Docker:**
-تشغيل وإيقاف وتعليق وحذف الحاويات. عرض إحصائيات الحاويات. التحكم في الحاوية باستخدام طرفية docker exec. لم يُصمم ليحل محل Portainer أو Dockge بل لإدارة حاوياتك ببساطة مقارنة بإنشائها.
+**إدارة Docker و Podman:**
+تشغيل وإيقاف وتعليق وحذف الحاويات. عرض إحصائيات الحاويات. التحكم في الحاوية باستخدام طرفية docker exec. يدعم كلاً من Docker و Podman كبيئة تشغيل للحاويات. لم يُصمم ليحل محل Portainer أو Dockge بل لإدارة حاوياتك ببساطة مقارنة بإنشائها.
@@ -115,9 +122,37 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا
+**تكامل Tailscale:**
+عرض أجهزة شبكتك من Tailscale لإضافتها بسرعة كمضيفات، والاتصال عبر Tailscale SSH كطريقة مصادقة، مما يتيح لقوائم تحكم الوصول في Tailscale التعامل مع التفويض دون الحاجة لتخزين بيانات اعتماد.
+
+
+
+
**RBAC:**
إنشاء الأدوار ومشاركة المضيفات عبر المستخدمين/الأدوار.
+
+
+
+
+
+**الاتصالات التسلسلية:**
+الاتصال بالأجهزة التسلسلية (أجهزة التوجيه والمفاتيح والمتحكمات الدقيقة وغيرها) مباشرة من المتصفح أو تطبيق سطح المكتب. ضبط معدل نقل البيانات وبتات البيانات وبتات التوقف والتكافؤ. يستخدم Web Serial API في المتصفحات المدعومة أو خلفية أصلية في تطبيق Electron.
+
+
+
+
+**التنبيهات:**
+ضبط قواعد تنبيه قائمة على الحدود لمقاييس المضيف (المعالج والذاكرة والقرص وغيرها) والحصول على إشعارات عبر ntfy أو webhooks عند إطلاقها. عرض التنبيهات النشطة والمحلولة في سجل التاريخ.
+
+
+
+
+
+
+**الصفحة الرئيسية:**
+صفحة رئيسية قابلة للتخصيص بالكامل مع شبكة أدوات قابلة للسحب والإفلات. أضف أدوات لحالة المضيف وروابط الخدمات والساعات والملاحظات وخلاصات RSS والطقس وحاويات Docker ومخططات مقاييس المضيف والطرفيات المضمنة والإطارات المضمنة وأكثر.
+
@@ -255,9 +290,9 @@ networks:
## التبرع
-Termix مجاني ومفتوح المصدر بدون اشتراكات أو خطط مدفوعة. إذا وجدته مفيدًا، فكّر في التبرع للمساعدة في تغطية تكاليف الخادم والنطاقات ووقت التطوير.
+Termix مجاني ومفتوح المصدر. إذا وجدته مفيدًا، فكّر في [التبرع](https://donate.termix.site/) للمساعدة في تغطية تكاليف الخادم ووقت التطوير.
-[تبرع](https://donate.termix.site/)
+
@@ -303,6 +338,10 @@ Termix مجاني ومفتوح المصدر بدون اشتراكات أو خط
+
+
+
+
قد تكون بعض مقاطع الفيديو والصور قديمة أو قد لا تعرض الميزات بشكل مثالي.
@@ -313,7 +352,7 @@ Termix مجاني ومفتوح المصدر بدون اشتراكات أو خط
## الميزات المخططة
-راجع [المشاريع](https://github.com/orgs/Termix-SSH/projects/2) لعرض جميع الميزات المخططة. إذا كنت تتطلع للمساهمة، راجع [المساهمة](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
+راجع [المشاريع](https://github.com/orgs/Termix-SSH/projects/5) لعرض جميع الميزات المخططة. إذا كنت تتطلع للمساهمة، راجع [المساهمة](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
diff --git a/readme/README-CN.md b/readme/README-CN.md
index 8f0ae44c..c4fa5331 100644
--- a/readme/README-CN.md
+++ b/readme/README-CN.md
@@ -28,10 +28,17 @@
+
+Termix 免费且开源。如果您觉得它有用,请考虑[捐赠](https://donate.termix.site/)以帮助支付服务器费用和开发时间。
+
+
+
+
+
@@ -87,8 +94,8 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
某些视频和图像可能已过时,或者可能无法完美展示功能。
@@ -313,7 +352,7 @@ Termix 是免费且开源的,没有订阅或付费计划。如果您觉得它
## 计划功能
-查看 [Projects](https://github.com/orgs/Termix-SSH/projects/2) 了解所有计划功能。如果您想贡献代码,请参阅 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)。
+查看 [Projects](https://github.com/orgs/Termix-SSH/projects/5) 了解所有计划功能。如果您想贡献代码,请参阅 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)。
diff --git a/readme/README-DE.md b/readme/README-DE.md
index 5c2d5514..deb0836f 100644
--- a/readme/README-DE.md
+++ b/readme/README-DE.md
@@ -28,10 +28,17 @@
+
+Termix ist kostenlos und Open Source. Wenn Sie es nützlich finden, erwägen Sie eine [Spende](https://donate.termix.site/), um Serverkosten und Entwicklungszeit zu decken.
+
+
+
+
+
@@ -87,8 +94,8 @@ Verwalten Sie Dateien direkt auf Remote-Servern mit Unterstutzung fur das Anzeig
-**Docker-Verwaltung:**
-Container starten, stoppen, pausieren, entfernen. Container-Statistiken anzeigen. Container uber Docker-Exec-Terminal steuern. Es wurde nicht entwickelt, um Portainer oder Dockge zu ersetzen, sondern um Ihre Container einfach zu verwalten, anstatt sie zu erstellen.
+**Docker- und Podman-Verwaltung:**
+Container starten, stoppen, pausieren, entfernen. Container-Statistiken anzeigen. Container uber Docker-Exec-Terminal steuern. Unterstutzt sowohl Docker als auch Podman als Container-Laufzeitumgebung. Es wurde nicht entwickelt, um Portainer oder Dockge zu ersetzen, sondern um Ihre Container einfach zu verwalten, anstatt sie zu erstellen.
@@ -115,9 +122,37 @@ Sichere Benutzerverwaltung mit Admin-Kontrollen und OIDC-/LDAP-/SSO-Unterstutzun
+**Tailscale-Integration:**
+Gerate aus Ihrem Tailnet auflisten, um sie schnell als Hosts hinzuzufugen, und mit Tailscale SSH als Authentifizierungsmethode verbinden, sodass Ihre Tailnet-ACLs die Autorisierung ubernehmen, ohne Anmeldedaten speichern zu mussen.
+
+
+
+
**RBAC:**
Rollen erstellen und Hosts uber Benutzer/Rollen teilen.
+
+
+
+
+
+**Serielle Verbindungen:**
+Verbinden Sie sich direkt vom Browser oder der Desktop-App aus mit seriellen Geraten (Router, Switches, Mikrocontroller usw.). Konfigurieren Sie Baudrate, Datenbits, Stoppbits und Paritat. Verwendet die Web Serial API in unterstutzten Browsern oder ein natives Backend in der Electron-App.
+
+
+
+
+**Warnmeldungen:**
+Legen Sie schwellenwertbasierte Warnregeln fur Host-Metriken (CPU, Arbeitsspeicher, Festplatte usw.) fest und erhalten Sie Benachrichtigungen uber ntfy oder Webhooks, wenn diese ausgelost werden. Zeigen Sie ausgeloste und aufgeloste Warnmeldungen in einem Verlaufsprotokoll an.
+
+
+
+
+
+
+**Startseite:**
+Eine vollstandig anpassbare Startseite mit einem Drag-and-Drop-Widget-Raster. Fugen Sie Widgets fur Hoststatus, Service-Links, Uhren, Notizen, RSS-Feeds, Wetter, Docker-Container, Host-Metrik-Diagramme, eingebettete Terminals, iFrames und mehr hinzu.
+
@@ -255,9 +290,9 @@ networks:
## Spenden
-Termix ist kostenlos und Open Source ohne Abonnements oder kostenpflichtige Pläne. Wenn Sie es nützlich finden, erwägen Sie eine Spende, um Serverkosten, Domains und Entwicklungszeit zu decken.
+Termix ist kostenlos und Open Source. Wenn Sie es nützlich finden, erwägen Sie eine [Spende](https://donate.termix.site/), um Serverkosten und Entwicklungszeit zu decken.
-[Spenden](https://donate.termix.site/)
+
@@ -303,6 +338,10 @@ Termix ist kostenlos und Open Source ohne Abonnements oder kostenpflichtige Plä
+
+
+
+
Einige Videos und Bilder konnen veraltet sein oder Funktionen moglicherweise nicht perfekt darstellen.
@@ -313,7 +352,7 @@ Termix ist kostenlos und Open Source ohne Abonnements oder kostenpflichtige Plä
## Geplante Funktionen
-Siehe [Projekte](https://github.com/orgs/Termix-SSH/projects/2) fur alle geplanten Funktionen. Wenn Sie beitragen mochten, siehe [Mitwirken](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
+Siehe [Projekte](https://github.com/orgs/Termix-SSH/projects/5) fur alle geplanten Funktionen. Wenn Sie beitragen mochten, siehe [Mitwirken](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
diff --git a/readme/README-ES.md b/readme/README-ES.md
index 6b164c21..041d3f15 100644
--- a/readme/README-ES.md
+++ b/readme/README-ES.md
@@ -28,10 +28,17 @@
+
+Termix es gratuito y de código abierto. Si lo encuentras útil, considera [donar](https://donate.termix.site/) para ayudar a cubrir los costos del servidor y el tiempo de desarrollo.
+
+
+
+
+
@@ -87,8 +94,8 @@ Gestione archivos directamente en servidores remotos con soporte para visualizar
-**Gestion de Docker:**
-Inicie, detenga, pause, elimine contenedores. Vea estadisticas 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.
+**Gestion de Docker y Podman:**
+Inicie, detenga, pause, elimine contenedores. Vea estadisticas de contenedores. Controle contenedores usando el terminal docker exec. Compatible con Docker y Podman como entorno de ejecucion de contenedores. No fue creado para reemplazar Portainer o Dockge, sino para simplemente gestionar sus contenedores en lugar de crearlos.
@@ -115,9 +122,37 @@ Gestion segura de usuarios con controles de administrador y soporte para OIDC/LD
+**Integracion con Tailscale:**
+Liste dispositivos de su red Tailscale para agregarlos rapidamente como hosts y conectese usando Tailscale SSH como metodo de autenticacion, permitiendo que las ACL de Tailscale gestionen la autorizacion sin almacenar credenciales.
+
+
+
+
**RBAC:**
Cree roles y comparta hosts entre usuarios/roles.
+
+
+
+
+
+**Conexiones Serie:**
+Conectese a dispositivos serie (routers, switches, microcontroladores, etc.) directamente desde el navegador o la aplicacion de escritorio. Configure la tasa de baudios, bits de datos, bits de parada y paridad. Utiliza la Web Serial API en navegadores compatibles o un backend nativo en la aplicacion Electron.
+
+
+
+
+**Alertas:**
+Configure reglas de alerta basadas en umbrales para metricas del host (CPU, memoria, disco, etc.) y reciba notificaciones a traves de ntfy o webhooks cuando se activen. Vea las alertas activas y resueltas en un historial de registros.
+
+
+
+
+
+
+**Pagina de Inicio:**
+Una pagina de inicio completamente personalizable con una cuadricula de widgets de arrastrar y soltar. Agregue widgets para estado del host, enlaces de servicios, relojes, notas, feeds RSS, clima, contenedores Docker, graficos de metricas del host, terminales integrados, iframes y mas.
+
@@ -255,9 +290,9 @@ networks:
## Donar
-Termix es gratuito y de código abierto sin suscripciones ni planes de pago. Si lo encuentras útil, considera hacer una donación para ayudar a cubrir los costos del servidor, dominios y tiempo de desarrollo.
+Termix es gratuito y de código abierto. Si lo encuentras útil, considera [donar](https://donate.termix.site/) para ayudar a cubrir los costos del servidor y el tiempo de desarrollo.
-[Donar](https://donate.termix.site/)
+
@@ -303,6 +338,10 @@ Termix es gratuito y de código abierto sin suscripciones ni planes de pago. Si
+
+
+
+
Algunos videos e imagenes pueden estar desactualizados o no mostrar perfectamente las caracteristicas.
@@ -313,7 +352,7 @@ Termix es gratuito y de código abierto sin suscripciones ni planes de pago. Si
## Caracteristicas Planeadas
-Consulte [Proyectos](https://github.com/orgs/Termix-SSH/projects/2) para todas las caracteristicas planeadas. Si desea contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
+Consulte [Proyectos](https://github.com/orgs/Termix-SSH/projects/5) para todas las caracteristicas planeadas. Si desea contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
diff --git a/readme/README-FR.md b/readme/README-FR.md
index 0915f825..c32ae6ba 100644
--- a/readme/README-FR.md
+++ b/readme/README-FR.md
@@ -28,10 +28,17 @@
+
+Termix est gratuit et open source. Si vous le trouvez utile, pensez à [faire un don](https://donate.termix.site/) pour aider à couvrir les coûts de serveur et le temps de développement.
+
+
+
+
+
@@ -87,8 +94,8 @@ Gerez les fichiers directement sur les serveurs distants avec support de la visu
-**Gestion Docker:**
-Demarrez, arretez, mettez en pause, supprimez des conteneurs. Consultez les statistiques des conteneurs. Controlez les conteneurs via le terminal docker exec. Non concu pour remplacer Portainer ou Dockge, mais plutot pour gerer simplement vos conteneurs plutot que de les creer.
+**Gestion Docker et Podman:**
+Demarrez, arretez, mettez en pause, supprimez des conteneurs. Consultez les statistiques des conteneurs. Controlez les conteneurs via le terminal docker exec. Compatible avec Docker et Podman comme environnement d'execution de conteneurs. Non concu pour remplacer Portainer ou Dockge, mais plutot pour gerer simplement vos conteneurs plutot que de les creer.
@@ -115,9 +122,37 @@ Gestion securisee des utilisateurs avec controles administrateur et support OIDC
+**Integration Tailscale:**
+Listez les appareils de votre reseau Tailscale pour les ajouter rapidement comme hotes, et connectez-vous en utilisant Tailscale SSH comme methode d'authentification, laissant les ACL de votre reseau gerer l'autorisation sans stocker de credentials.
+
+
+
+
**RBAC:**
Creez des roles et partagez des hotes entre utilisateurs/roles.
+
+
+
+
+
+**Connexions Serie:**
+Connectez-vous a des appareils serie (routeurs, commutateurs, microcontroleurs, etc.) directement depuis le navigateur ou l'application bureau. Configurez le debit en bauds, les bits de donnees, les bits d'arret et la parite. Utilise l'API Web Serial dans les navigateurs compatibles ou un backend natif dans l'application Electron.
+
+
+
+
+**Alertes:**
+Definissez des regles d'alerte basees sur des seuils pour les metriques d'hote (CPU, memoire, disque, etc.) et recevez des notifications via ntfy ou webhooks lorsqu'elles se declenchent. Consultez les alertes actives et resolues dans un journal d'historique.
+
+
+
+
+
+
+**Page d'accueil:**
+Une page d'accueil entierement personnalisable avec une grille de widgets glisser-deposer. Ajoutez des widgets pour l'etat des hotes, les liens de services, les horloges, les notes, les flux RSS, la meteo, les conteneurs Docker, les graphiques de metriques d'hote, les terminaux integres, les iframes et plus encore.
+
@@ -255,9 +290,9 @@ networks:
## Faire un don
-Termix est gratuit et open source sans abonnements ni plans payants. Si vous le trouvez utile, pensez à faire un don pour aider à couvrir les coûts de serveur, les noms de domaine et le temps de développement.
+Termix est gratuit et open source. Si vous le trouvez utile, pensez à [faire un don](https://donate.termix.site/) pour aider à couvrir les coûts de serveur et le temps de développement.
-[Faire un don](https://donate.termix.site/)
+
@@ -303,6 +338,10 @@ Termix est gratuit et open source sans abonnements ni plans payants. Si vous le
+
+
+
+
Certaines videos et images peuvent etre obsoletes ou ne pas presenter parfaitement les fonctionnalites.
@@ -313,7 +352,7 @@ Termix est gratuit et open source sans abonnements ni plans payants. Si vous le
## Fonctionnalites prevues
-Consultez les [Projects](https://github.com/orgs/Termix-SSH/projects/2) pour toutes les fonctionnalites prevues. Si vous souhaitez contribuer, consultez [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
+Consultez les [Projects](https://github.com/orgs/Termix-SSH/projects/5) pour toutes les fonctionnalites prevues. Si vous souhaitez contribuer, consultez [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
diff --git a/readme/README-HI.md b/readme/README-HI.md
index 04e83adc..d88917de 100644
--- a/readme/README-HI.md
+++ b/readme/README-HI.md
@@ -28,10 +28,17 @@
+
+Termix मुफ़्त और ओपन सोर्स है। यदि आपको यह उपयोगी लगता है, तो सर्वर लागत और विकास समय में मदद के लिए [दान करें](https://donate.termix.site/)।
+
+
+
+
+
@@ -87,8 +94,8 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
-**Docker प्रबंधन:**
-कंटेनर शुरू, बंद, पॉज़, हटाएँ। कंटेनर स्टैट्स देखें। docker exec टर्मिनल का उपयोग करके कंटेनर को नियंत्रित करें। इसे Portainer या Dockge की जगह लेने के लिए नहीं बनाया गया बल्कि कंटेनर बनाने की तुलना में उन्हें सरलता से प्रबंधित करने के लिए बनाया गया है।
+**Docker और Podman प्रबंधन:**
+कंटेनर शुरू, बंद, पॉज़, हटाएँ। कंटेनर स्टैट्स देखें। docker exec टर्मिनल का उपयोग करके कंटेनर को नियंत्रित करें। Docker और Podman दोनों को कंटेनर रनटाइम के रूप में सपोर्ट करता है। इसे Portainer या Dockge की जगह लेने के लिए नहीं बनाया गया बल्कि कंटेनर बनाने की तुलना में उन्हें सरलता से प्रबंधित करने के लिए बनाया गया है।
@@ -115,9 +122,37 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
+**Tailscale एकीकरण:**
+अपने Tailscale नेटवर्क के डिवाइस सूचीबद्ध करें ताकि उन्हें जल्दी से होस्ट के रूप में जोड़ा जा सके, और Tailscale SSH को प्रमाणीकरण विधि के रूप में उपयोग करके कनेक्ट करें, जिससे आपके Tailscale ACL क्रेडेंशियल संग्रहीत किए बिना प्राधिकरण संभाल सकें।
+
+
+
+
**RBAC:**
भूमिकाएँ बनाएँ और उपयोगकर्ताओं/भूमिकाओं में होस्ट साझा करें।
+
+
+
+
+
+**सीरियल कनेक्शन:**
+सीरियल डिवाइस (राउटर, स्विच, माइक्रोकंट्रोलर आदि) से सीधे ब्राउज़र या डेस्कटॉप ऐप से कनेक्ट करें। बॉड रेट, डेटा बिट्स, स्टॉप बिट्स और पैरिटी कॉन्फ़िगर करें। समर्थित ब्राउज़र में Web Serial API या Electron ऐप में नेटिव बैकएंड का उपयोग करता है।
+
+
+
+
+**अलर्ट:**
+होस्ट मेट्रिक्स (CPU, मेमोरी, डिस्क आदि) पर थ्रेशोल्ड-आधारित अलर्ट नियम सेट करें और जब वे ट्रिगर हों तो ntfy या webhooks के माध्यम से सूचना पाएँ। इतिहास लॉग में सक्रिय और हल किए गए अलर्ट देखें।
+
+
+
+
+
+
+**होमपेज:**
+ड्रैग-एंड-ड्रॉप विजेट ग्रिड के साथ पूरी तरह से कस्टमाइज़ करने योग्य होमपेज। होस्ट स्टेटस, सर्विस लिंक, घड़ियाँ, नोट्स, RSS फ़ीड, मौसम, Docker कंटेनर, होस्ट मेट्रिक्स चार्ट, एम्बेडेड टर्मिनल, iframes और अन्य के लिए विजेट जोड़ें।
+
@@ -255,9 +290,9 @@ networks:
## दान करें
-Termix मुफ़्त और ओपन सोर्स है, इसमें कोई सदस्यता या भुगतान योजना नहीं है। यदि आपको यह उपयोगी लगता है, तो सर्वर लागत, डोमेन और विकास समय को कवर करने में मदद के लिए दान करने पर विचार करें।
+Termix मुफ़्त और ओपन सोर्स है। यदि आपको यह उपयोगी लगता है, तो सर्वर लागत और विकास समय में मदद के लिए [दान करें](https://donate.termix.site/)।
-[दान करें](https://donate.termix.site/)
+
@@ -303,6 +338,10 @@ Termix मुफ़्त और ओपन सोर्स है, इसमे
+
+
+
+
कुछ वीडियो और छवियाँ पुरानी हो सकती हैं या विशेषताओं को पूरी तरह से प्रदर्शित नहीं कर सकती हैं।
@@ -313,7 +352,7 @@ Termix मुफ़्त और ओपन सोर्स है, इसमे
## नियोजित विशेषताएँ
-सभी नियोजित विशेषताओं के लिए [प्रोजेक्ट्स](https://github.com/orgs/Termix-SSH/projects/2) देखें। यदि आप योगदान देना चाहते हैं, तो [योगदान](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) देखें।
+सभी नियोजित विशेषताओं के लिए [प्रोजेक्ट्स](https://github.com/orgs/Termix-SSH/projects/5) देखें। यदि आप योगदान देना चाहते हैं, तो [योगदान](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) देखें।
diff --git a/readme/README-IT.md b/readme/README-IT.md
index 2f622b22..278430ae 100644
--- a/readme/README-IT.md
+++ b/readme/README-IT.md
@@ -28,10 +28,17 @@
+
+Termix è gratuito e open source. Se lo trovi utile, considera di [donare](https://donate.termix.site/) per aiutare a coprire i costi del server e il tempo di sviluppo.
+
+
+
+
+
@@ -87,8 +94,8 @@ Gestisci i file direttamente sui server remoti con supporto per la visualizzazio
-**Gestione Docker:**
-Avvia, ferma, metti in pausa, rimuovi container. Visualizza le statistiche dei container. Controlla i container tramite terminale docker exec. Non e stato creato per sostituire Portainer o Dockge, ma piuttosto per gestire semplicemente i tuoi container rispetto alla loro creazione.
+**Gestione Docker e Podman:**
+Avvia, ferma, metti in pausa, rimuovi container. Visualizza le statistiche dei container. Controlla i container tramite terminale docker exec. Supporta sia Docker che Podman come runtime dei container. Non e stato creato per sostituire Portainer o Dockge, ma piuttosto per gestire semplicemente i tuoi container rispetto alla loro creazione.
@@ -115,9 +122,37 @@ Gestione utenti sicura con controlli amministrativi e supporto OIDC/LDAP/SSO (co
+**Integrazione Tailscale:**
+Elenca i dispositivi della tua rete Tailscale per aggiungerli rapidamente come host, e connettiti utilizzando Tailscale SSH come metodo di autenticazione, lasciando che le ACL della tua rete gestiscano l'autorizzazione senza memorizzare credenziali.
+
+
+
+
**RBAC:**
Crea ruoli e condividi host tra utenti/ruoli.
+
+
+
+
+
+**Connessioni Seriali:**
+Connettiti a dispositivi seriali (router, switch, microcontrollori, ecc.) direttamente dal browser o dall'app desktop. Configura baud rate, bit di dati, bit di stop e parita. Utilizza la Web Serial API nei browser supportati o un backend nativo nell'app Electron.
+
+
+
+
+**Avvisi:**
+Imposta regole di avviso basate su soglie per le metriche dell'host (CPU, memoria, disco, ecc.) e ricevi notifiche tramite ntfy o webhook quando si attivano. Visualizza gli avvisi attivi e risolti in un registro storico.
+
+
+
+
+
+
+**Homepage:**
+Una homepage completamente personalizzabile con una griglia di widget drag-and-drop. Aggiungi widget per lo stato dell'host, link ai servizi, orologi, note, feed RSS, meteo, container Docker, grafici delle metriche dell'host, terminali incorporati, iframe e altro ancora.
+
@@ -255,9 +290,9 @@ networks:
## Dona
-Termix è gratuito e open source senza abbonamenti o piani a pagamento. Se lo trovi utile, considera di fare una donazione per aiutare a coprire i costi del server, i domini e il tempo di sviluppo.
+Termix è gratuito e open source. Se lo trovi utile, considera di [donare](https://donate.termix.site/) per aiutare a coprire i costi del server e il tempo di sviluppo.
-[Dona](https://donate.termix.site/)
+
@@ -303,6 +338,10 @@ Termix è gratuito e open source senza abbonamenti o piani a pagamento. Se lo tr
+
+
+
+
Alcuni video e immagini potrebbero non essere aggiornati o potrebbero non mostrare perfettamente le funzionalita.
@@ -313,7 +352,7 @@ Termix è gratuito e open source senza abbonamenti o piani a pagamento. Se lo tr
## Funzionalita Pianificate
-Consulta [Progetti](https://github.com/orgs/Termix-SSH/projects/2) per tutte le funzionalita pianificate. Se desideri contribuire, consulta [Contribuire](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
+Consulta [Progetti](https://github.com/orgs/Termix-SSH/projects/5) per tutte le funzionalita pianificate. Se desideri contribuire, consulta [Contribuire](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
diff --git a/readme/README-JA.md b/readme/README-JA.md
index 83b142fd..8980fcae 100644
--- a/readme/README-JA.md
+++ b/readme/README-JA.md
@@ -28,10 +28,17 @@
+
+Termix は無料のオープンソースプロジェクトです。便利だと感じた場合は、サーバーコストと開発時間のために[寄付](https://donate.termix.site/)をご検討ください。
+
+
+
+
+
@@ -87,8 +94,8 @@ Termixは、オープンソースで永久無料のセルフホスト型オー
動画や画像の一部は最新ではない場合や、機能を完全に紹介できていない場合があります。
@@ -313,7 +352,7 @@ Termix はサブスクリプションや有料プランのない、無料のオ
## 予定されている機能
-すべての予定機能については[Projects](https://github.com/orgs/Termix-SSH/projects/2)をご覧ください。コントリビュートをご希望の方は[Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)をご覧ください。
+すべての予定機能については[Projects](https://github.com/orgs/Termix-SSH/projects/5)をご覧ください。コントリビュートをご希望の方は[Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)をご覧ください。
diff --git a/readme/README-KO.md b/readme/README-KO.md
index 7fd2891d..4fee63ca 100644
--- a/readme/README-KO.md
+++ b/readme/README-KO.md
@@ -28,10 +28,17 @@
+
+Termix는 무료 오픈소스 프로젝트입니다. 유용하게 사용하고 있다면 서버 비용과 개발 시간을 위해 [후원](https://donate.termix.site/)을 고려해 주세요.
+
+
+
+
+
@@ -87,8 +94,8 @@ Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버
-**Docker 관리:**
-컨테이너 시작, 중지, 일시 정지, 제거. 컨테이너 통계 보기. docker exec 터미널로 컨테이너 제어. Portainer나 Dockge를 대체하기 위한 것이 아니라 컨테이너 생성보다는 간편한 관리를 목적으로 합니다.
+**Docker 및 Podman 관리:**
+컨테이너 시작, 중지, 일시 정지, 제거. 컨테이너 통계 보기. docker exec 터미널로 컨테이너 제어. Docker와 Podman을 모두 컨테이너 런타임으로 지원. Portainer나 Dockge를 대체하기 위한 것이 아니라 컨테이너 생성보다는 간편한 관리를 목적으로 합니다.
@@ -115,9 +122,37 @@ Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버
+**Tailscale 통합:**
+Tailscale 네트워크의 기기를 나열하여 호스트로 빠르게 추가하고, Tailscale SSH를 인증 방법으로 사용하여 연결함으로써 자격 증명을 저장하지 않고도 네트워크 ACL이 권한 부여를 처리하도록 합니다.
+
+
+
+
**RBAC:**
역할을 생성하고 사용자/역할 간에 호스트 공유.
+
+
+
+
+
+**시리얼 연결:**
+브라우저 또는 데스크톱 앱에서 직접 시리얼 장치(라우터, 스위치, 마이크로컨트롤러 등)에 연결. 보드레이트, 데이터 비트, 스톱 비트, 패리티 구성. 지원 브라우저에서는 Web Serial API를, Electron 앱에서는 네이티브 백엔드를 사용합니다.
+
+
+
+
+**알림:**
+호스트 메트릭(CPU, 메모리, 디스크 등)에 대한 임계값 기반 알림 규칙을 설정하고 트리거될 때 ntfy 또는 웹훅을 통해 알림 수신. 기록 로그에서 발생 중인 알림과 해결된 알림 확인.
+
+
+
+
+
+
+**홈페이지:**
+드래그 앤 드롭 위젯 그리드를 갖춘 완전 맞춤형 홈페이지. 호스트 상태, 서비스 링크, 시계, 메모, RSS 피드, 날씨, Docker 컨테이너, 호스트 메트릭 차트, 임베디드 터미널, iframe 등의 위젯 추가 가능.
+
@@ -255,9 +290,9 @@ networks:
## 후원
-Termix는 구독이나 유료 플랜 없이 무료 오픈소스 프로젝트입니다. 유용하게 사용하고 있다면 서버 비용, 도메인, 개발 시간을 지원하기 위한 후원을 고려해 주세요.
+Termix는 무료 오픈소스 프로젝트입니다. 유용하게 사용하고 있다면 서버 비용과 개발 시간을 위해 [후원](https://donate.termix.site/)을 고려해 주세요.
-[후원하기](https://donate.termix.site/)
+
@@ -303,6 +338,10 @@ Termix는 구독이나 유료 플랜 없이 무료 오픈소스 프로젝트입
+
+
+
+
일부 비디오 및 이미지는 최신이 아니거나 기능을 완벽하게 보여주지 않을 수 있습니다.
@@ -313,7 +352,7 @@ Termix는 구독이나 유료 플랜 없이 무료 오픈소스 프로젝트입
## 계획된 기능
-모든 계획된 기능은 [Projects](https://github.com/orgs/Termix-SSH/projects/2)를 참조하세요. 기여를 원하시면 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)을 참조하세요.
+모든 계획된 기능은 [Projects](https://github.com/orgs/Termix-SSH/projects/5)를 참조하세요. 기여를 원하시면 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)을 참조하세요.
diff --git a/readme/README-PT.md b/readme/README-PT.md
index 5325d6d9..47656bbd 100644
--- a/readme/README-PT.md
+++ b/readme/README-PT.md
@@ -28,10 +28,17 @@
+
+Termix é gratuito e de código aberto. Se o achar útil, considere [doar](https://donate.termix.site/) para ajudar a cobrir os custos de servidor e o tempo de desenvolvimento.
+
+
+
+
+
@@ -87,8 +94,8 @@ Gerencie arquivos diretamente em servidores remotos com suporte para visualizar
-**Gerenciamento de Docker:**
-Inicie, pare, pause, remova conteineres. Visualize estatisticas de conteineres. Controle conteineres usando o terminal Docker Exec. Nao foi feito para substituir Portainer ou Dockge, mas sim para simplesmente gerenciar seus conteineres em vez de cria-los.
+**Gerenciamento de Docker e Podman:**
+Inicie, pare, pause, remova conteineres. Visualize estatisticas de conteineres. Controle conteineres usando o terminal Docker Exec. Suporta Docker e Podman como ambiente de execucao de conteineres. Nao foi feito para substituir Portainer ou Dockge, mas sim para simplesmente gerenciar seus conteineres em vez de cria-los.
@@ -115,9 +122,37 @@ Gerenciamento seguro de usuarios com controles de administrador e suporte para O
+**Integracao com Tailscale:**
+Liste dispositivos da sua rede Tailscale para adicioná-los rapidamente como hosts, e conecte-se usando Tailscale SSH como metodo de autenticacao, deixando as ACLs da sua rede gerenciar a autorizacao sem armazenar credenciais.
+
+
+
+
**RBAC:**
Crie funcoes e compartilhe hosts entre usuarios/funcoes.
+
+
+
+
+
+**Conexoes Seriais:**
+Conecte-se a dispositivos seriais (roteadores, switches, microcontroladores, etc.) diretamente do navegador ou do aplicativo desktop. Configure taxa de baud, bits de dados, bits de parada e paridade. Usa a Web Serial API em navegadores suportados ou um backend nativo no aplicativo Electron.
+
+
+
+
+**Alertas:**
+Defina regras de alerta baseadas em limites para metricas do host (CPU, memoria, disco, etc.) e receba notificacoes via ntfy ou webhooks quando forem ativadas. Visualize alertas ativos e resolvidos em um historico de registros.
+
+
+
+
+
+
+**Pagina Inicial:**
+Uma pagina inicial totalmente personalizavel com uma grade de widgets de arrastar e soltar. Adicione widgets para status do host, links de servicos, relogios, notas, feeds RSS, clima, conteineres Docker, graficos de metricas do host, terminais incorporados, iframes e mais.
+
@@ -255,9 +290,9 @@ networks:
## Doar
-O Termix é gratuito e de código aberto, sem assinaturas ou planos pagos. Se o achar útil, considere fazer uma doação para ajudar a cobrir os custos de servidor, domínios e tempo de desenvolvimento.
+Termix é gratuito e de código aberto. Se o achar útil, considere [doar](https://donate.termix.site/) para ajudar a cobrir os custos de servidor e o tempo de desenvolvimento.
-[Doar](https://donate.termix.site/)
+
@@ -303,6 +338,10 @@ O Termix é gratuito e de código aberto, sem assinaturas ou planos pagos. Se o
+
+
+
+
Alguns videos e imagens podem estar desatualizados ou podem nao mostrar perfeitamente as funcionalidades.
@@ -313,7 +352,7 @@ O Termix é gratuito e de código aberto, sem assinaturas ou planos pagos. Se o
## Funcionalidades Planejadas
-Consulte [Projetos](https://github.com/orgs/Termix-SSH/projects/2) para todas as funcionalidades planejadas. Se voce deseja contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
+Consulte [Projetos](https://github.com/orgs/Termix-SSH/projects/5) para todas as funcionalidades planejadas. Se voce deseja contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
diff --git a/readme/README-RU.md b/readme/README-RU.md
index 8bc04f24..2ed46ed4 100644
--- a/readme/README-RU.md
+++ b/readme/README-RU.md
@@ -28,10 +28,17 @@
+
+Termix — бесплатный проект с открытым исходным кодом. Если он вам полезен, рассмотрите возможность [пожертвования](https://donate.termix.site/) для покрытия расходов на серверы и время разработки.
+
+
+
+
+
@@ -87,8 +94,8 @@ Termix - это платформа для управления серверам
-**Управление Docker:**
-Запуск, остановка, приостановка, удаление контейнеров. Просмотр статистики контейнеров. Управление контейнером через терминал docker exec. Не предназначен для замены Portainer или Dockge, а скорее для простого управления контейнерами по сравнению с их созданием.
+**Управление Docker и Podman:**
+Запуск, остановка, приостановка, удаление контейнеров. Просмотр статистики контейнеров. Управление контейнером через терминал docker exec. Поддерживает как Docker, так и Podman в качестве среды выполнения контейнеров. Не предназначен для замены Portainer или Dockge, а скорее для простого управления контейнерами по сравнению с их созданием.
@@ -115,9 +122,37 @@ Termix - это платформа для управления серверам
+**Интеграция с Tailscale:**
+Список устройств вашей сети Tailscale для быстрого добавления их в качестве хостов и подключение через Tailscale SSH в качестве метода аутентификации, позволяя ACL вашей сети управлять авторизацией без хранения учётных данных.
+
+
+
+
**RBAC:**
Создание ролей и предоставление общего доступа к хостам для пользователей/ролей.
+
+
+
+
+
+**Последовательные подключения:**
+Подключение к последовательным устройствам (маршрутизаторы, коммутаторы, микроконтроллеры и т. д.) напрямую из браузера или приложения для рабочего стола. Настройка скорости передачи данных, битов данных, стоп-битов и чётности. Использует Web Serial API в поддерживаемых браузерах или нативный бэкенд в приложении Electron.
+
+
+
+
+**Оповещения:**
+Настройте правила оповещений на основе пороговых значений для метрик хоста (CPU, память, диск и т. д.) и получайте уведомления через ntfy или вебхуки при их срабатывании. Просматривайте активные и разрешённые оповещения в журнале истории.
+
+
+
+
+
+
+**Домашняя страница:**
+Полностью настраиваемая домашняя страница с сеткой виджетов с перетаскиванием. Добавляйте виджеты для статуса хоста, ссылок на сервисы, часов, заметок, RSS-лент, погоды, контейнеров Docker, графиков метрик хоста, встроенных терминалов, iframe и многого другого.
+
@@ -255,9 +290,9 @@ networks:
## Пожертвование
-Termix — бесплатный проект с открытым исходным кодом без подписок и платных планов. Если вы находите его полезным, рассмотрите возможность пожертвования для покрытия расходов на серверы, домены и время разработки.
+Termix — бесплатный проект с открытым исходным кодом. Если он вам полезен, рассмотрите возможность [пожертвования](https://donate.termix.site/) для покрытия расходов на серверы и время разработки.
-[Пожертвовать](https://donate.termix.site/)
+
@@ -303,6 +338,10 @@ Termix — бесплатный проект с открытым исходны
+
+
+
+
Некоторые видео и изображения могут быть устаревшими или не полностью отражать функциональность.
@@ -313,7 +352,7 @@ Termix — бесплатный проект с открытым исходны
## Запланированные функции
-Смотрите [Проекты](https://github.com/orgs/Termix-SSH/projects/2) для просмотра всех запланированных функций. Если вы хотите внести вклад, смотрите [Участие в разработке](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
+Смотрите [Проекты](https://github.com/orgs/Termix-SSH/projects/5) для просмотра всех запланированных функций. Если вы хотите внести вклад, смотрите [Участие в разработке](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
diff --git a/readme/README-TR.md b/readme/README-TR.md
index e11b6a7d..571d0cc8 100644
--- a/readme/README-TR.md
+++ b/readme/README-TR.md
@@ -28,10 +28,17 @@
+
+Termix ücretsiz ve açık kaynaklıdır. Faydalı buluyorsanız, sunucu maliyetleri ve geliştirme süresine katkıda bulunmak için [bağış yapmayı](https://donate.termix.site/) düşünebilirsiniz.
+
+
+
+
+
@@ -87,8 +94,8 @@ Uzak sunuculardaki dosyalari dogrudan yonetin; kod, goruntu, ses ve video gorunt
-**Docker Yonetimi:**
-Konteynerleri baslatın, durdurun, duraklatın, kaldirin. Konteyner istatistiklerini goruntuleyin. Docker exec terminali kullanarak konteyneri kontrol edin. Portainer veya Dockge'nin yerini almak icin degil, konteynerlerinizi olusturmak yerine basitce yonetmek icin tasarlanmistir.
+**Docker ve Podman Yonetimi:**
+Konteynerleri baslatın, durdurun, duraklatın, kaldirin. Konteyner istatistiklerini goruntuleyin. Docker exec terminali kullanarak konteyneri kontrol edin. Docker ve Podman'i konteyner calisma ortami olarak destekler. Portainer veya Dockge'nin yerini almak icin degil, konteynerlerinizi olusturmak yerine basitce yonetmek icin tasarlanmistir.
@@ -115,9 +122,37 @@ Yonetici kontrolleri, OIDC/LDAP/SSO (erisim kontrollu) ve 2FA (TOTP) destegi ile
+**Tailscale Entegrasyonu:**
+Tailscale aginizdaki cihazlari listeleyerek hizlica ana bilgisayar olarak ekleyin ve kimlik dogrulama yontemi olarak Tailscale SSH kullanarak baglanin; bu sayede ag ACL'leriniz kimlik bilgileri depolamadan yetkilendirmeyi yonetir.
+
+
+
+
**RBAC:**
Roller olusturun ve ana bilgisayarlari kullanicilar/roller arasinda paylasin.
+
+
+
+
+
+**Seri Baglantilar:**
+Seri cihazlara (router, switch, mikrodenetleyici vb.) dogrudan tarayici veya masaustu uygulamasindan baglanin. Baud hizi, veri bitleri, durdurma bitleri ve parite yapilandirin. Desteklenen tarayicilarda Web Serial API, Electron uygulamasinda yerel arka ucu kullanir.
+
+
+
+
+**Uyarilar:**
+Ana bilgisayar metrikleri (CPU, bellek, disk vb.) icin esik tabanli uyari kurallari belirleyin ve tetiklendiklerinde ntfy veya webhook araciligiyla bildirim alin. Gecmis gunlugunde tetiklenen ve cozulen uyarilari goruntuleyin.
+
+
+
+
+
+
+**Ana Sayfa:**
+Surukleme ve birakma widget izgarasina sahip tamamen ozerlestirilebilir bir ana sayfa. Ana bilgisayar durumu, hizmet baglantilari, saatler, notlar, RSS besleme, hava durumu, Docker konteynerleri, ana bilgisayar metrik grafikleri, gomulu terminaller, iframe ve daha fazlasi icin widget ekleyin.
+
@@ -255,9 +290,9 @@ networks:
## Bağış Yapın
-Termix, abonelik veya ücretli plan olmayan ücretsiz ve açık kaynaklı bir projedir. Faydalı buluyorsanız, sunucu maliyetleri, alan adları ve geliştirme süresini karşılamaya yardımcı olmak için bağış yapmayı düşünebilirsiniz.
+Termix ücretsiz ve açık kaynaklıdır. Faydalı buluyorsanız, sunucu maliyetleri ve geliştirme süresine katkıda bulunmak için [bağış yapmayı](https://donate.termix.site/) düşünebilirsiniz.
-[Bağış Yap](https://donate.termix.site/)
+
@@ -303,6 +338,10 @@ Termix, abonelik veya ücretli plan olmayan ücretsiz ve açık kaynaklı bir pr
+
+
+
+
Bazi videolar ve gorseller guncel olmayabilir veya ozellikleri tam olarak yansitmayabilir.
@@ -313,7 +352,7 @@ Termix, abonelik veya ücretli plan olmayan ücretsiz ve açık kaynaklı bir pr
## Planlanan Ozellikler
-Tum planlanan ozellikler icin [Projeler](https://github.com/orgs/Termix-SSH/projects/2) sayfasina bakin. Katkida bulunmak istiyorsaniz, [Katkida Bulunma](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) sayfasina bakin.
+Tum planlanan ozellikler icin [Projeler](https://github.com/orgs/Termix-SSH/projects/5) sayfasina bakin. Katkida bulunmak istiyorsaniz, [Katkida Bulunma](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) sayfasina bakin.
diff --git a/readme/README-VI.md b/readme/README-VI.md
index 7779af67..c0038d8c 100644
--- a/readme/README-VI.md
+++ b/readme/README-VI.md
@@ -28,10 +28,17 @@
+
+Termix là dự án miễn phí và mã nguồn mở. Nếu bạn thấy hữu ích, hãy cân nhắc [quyên góp](https://donate.termix.site/) để giúp trang trải chi phí máy chủ và thời gian phát triển.
+
+
+
+
+
@@ -87,8 +94,8 @@ Quan ly tep truc tiep tren may chu tu xa voi ho tro xem va chinh sua ma, hinh an
-**Quan Ly Docker:**
-Khoi dong, dung, tam dung, xoa container. Xem thong ke container. Dieu khien container bang terminal docker exec. Khong duoc tao ra de thay the Portainer hay Dockge ma don gian la de quan ly container cua ban thay vi tao moi chung.
+**Quan Ly Docker va Podman:**
+Khoi dong, dung, tam dung, xoa container. Xem thong ke container. Dieu khien container bang terminal docker exec. Ho tro ca Docker va Podman lam moi truong chay container. Khong duoc tao ra de thay the Portainer hay Dockge ma don gian la de quan ly container cua ban thay vi tao moi chung.
@@ -115,9 +122,37 @@ Quan ly nguoi dung an toan voi quyen quan tri va ho tro OIDC/LDAP/SSO (co kiem s
+**Tich Hop Tailscale:**
+Liet ke cac thiet bi trong mang Tailscale de nhanh chong them vao lam may chu, va ket noi bang Tailscale SSH lam phuong thuc xac thuc, de cac ACL mang xu ly uy quyen ma khong can luu tru thong tin xac thuc.
+
+
+
+
**RBAC:**
Tao vai tro va chia se may chu giua nguoi dung/vai tro.
+
+
+
+
+
+**Ket Noi Noi Tiep:**
+Ket noi voi cac thiet bi noi tiep (router, switch, vi dieu khien, v.v.) truc tiep tu trinh duyet hoac ung dung may tinh. Cau hinh toc do baud, bit du lieu, bit dung va chan le. Su dung Web Serial API tren trinh duyet duoc ho tro hoac backend ban dia trong ung dung Electron.
+
+
+
+
+**Canh Bao:**
+Dat cac quy tac canh bao dua tren nguong cho chi so may chu (CPU, bo nho, o dia, v.v.) va nhan thong bao qua ntfy hoac webhook khi chung khi toa. Xem canh bao dang kich hoat va da giai quyet trong nhat ky lich su.
+
+
+
+
+
+
+**Trang Chu:**
+Trang chu co the tuy chinh hoan toan voi luoi widget keo va tha. Them widget cho trang thai may chu, lien ket dich vu, dong ho, ghi chu, feed RSS, thoi tiet, container Docker, bieu do chi so may chu, terminal nhung, iframe va nhieu hon nua.
+
@@ -255,9 +290,9 @@ networks:
## Quyên góp
-Termix là dự án miễn phí và mã nguồn mở, không có gói đăng ký hay trả phí. Nếu bạn thấy hữu ích, hãy cân nhắc quyên góp để giúp trang trải chi phí máy chủ, tên miền và thời gian phát triển.
+Termix là dự án miễn phí và mã nguồn mở. Nếu bạn thấy hữu ích, hãy cân nhắc [quyên góp](https://donate.termix.site/) để giúp trang trải chi phí máy chủ và thời gian phát triển.
-[Quyên góp](https://donate.termix.site/)
+
@@ -303,6 +338,10 @@ Termix là dự án miễn phí và mã nguồn mở, không có gói đăng ký
+
+
+
+
Mot so video va hinh anh co the da loi thoi hoac khong the hien chinh xac hoan toan cac tinh nang.
@@ -313,7 +352,7 @@ Termix là dự án miễn phí và mã nguồn mở, không có gói đăng ký
## Tinh Nang Du Kien
-Xem [Du An](https://github.com/orgs/Termix-SSH/projects/2) de biet tat ca cac tinh nang du kien. Neu ban muon dong gop, xem [Dong Gop](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
+Xem [Du An](https://github.com/orgs/Termix-SSH/projects/5) de biet tat ca cac tinh nang du kien. Neu ban muon dong gop, xem [Dong Gop](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
diff --git a/repo-images/Image 15.png b/repo-images/Image 15.png
new file mode 100644
index 00000000..4180e3ba
Binary files /dev/null and b/repo-images/Image 15.png differ
diff --git a/repo-images/Image 16.png b/repo-images/Image 16.png
new file mode 100644
index 00000000..8837bb97
Binary files /dev/null and b/repo-images/Image 16.png differ
diff --git a/repo-images/donation-goal.svg b/repo-images/donation-goal.svg
new file mode 100644
index 00000000..e63bc49f
--- /dev/null
+++ b/repo-images/donation-goal.svg
@@ -0,0 +1,8 @@
+
diff --git a/scripts/crowdin-pretranslate.cjs b/scripts/crowdin-pretranslate.cjs
index 822f5adf..32ffcb20 100644
--- a/scripts/crowdin-pretranslate.cjs
+++ b/scripts/crowdin-pretranslate.cjs
@@ -63,7 +63,7 @@ async function resolveFileId(projectId) {
}
async function pollPreTranslation(projectId, preTranslationId) {
- for (let i = 0; i < 120; i++) {
+ for (;;) {
const { data } = await request(
"GET",
`/projects/${projectId}/pre-translations/${preTranslationId}`,
@@ -76,7 +76,6 @@ async function pollPreTranslation(projectId, preTranslationId) {
}
await new Promise((r) => setTimeout(r, 5000));
}
- throw new Error("pre-translation timed out after 10 minutes");
}
async function main() {
diff --git a/scripts/patch-guacamole-lite.cjs b/scripts/patch-guacamole-lite.cjs
index f3889153..e1862a96 100644
--- a/scripts/patch-guacamole-lite.cjs
+++ b/scripts/patch-guacamole-lite.cjs
@@ -1,7 +1,7 @@
const fs = require("fs");
const path = require("path");
-const filePath = path.join(
+const guacdClientPath = path.join(
__dirname,
"..",
"node_modules",
@@ -9,13 +9,22 @@ const filePath = path.join(
"lib",
"GuacdClient.js",
);
+const cryptPath = path.join(
+ __dirname,
+ "..",
+ "node_modules",
+ "guacamole-lite",
+ "lib",
+ "Crypt.js",
+);
-if (!fs.existsSync(filePath)) {
+if (!fs.existsSync(guacdClientPath) || !fs.existsSync(cryptPath)) {
console.log("[patch-guacamole-lite] File not found, skipping");
process.exit(0);
}
-let content = fs.readFileSync(filePath, "utf8");
+let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
+let cryptContent = fs.readFileSync(cryptPath, "utf8");
// Patch 1: version acceptance list
const oldVersionCheck = "if (version === '1_0_0' || version === '1_1_0') {";
@@ -41,36 +50,185 @@ const newConnect =
"\n" +
" this.sendInstruction(['connect'].concat(connectArgs));";
+// Patch 4: answer guacd's dynamic argument requests locally.
+// macOS Screen Sharing can request VNC username/password through the
+// post-handshake `required`/`require` flow. guacamole-lite forwards those
+// instructions to the browser, but Termix already keeps the credentials in the
+// server-side token and the browser does not provide an onrequired handler.
+const oldSendBuffer =
+ " this.lastActivity = Date.now();\n" + " this.sendBuffer = '';";
+const newSendBuffer =
+ " this.lastActivity = Date.now();\n" +
+ " this.sendBuffer = '';\n" +
+ " this.nextArgumentStreamIndex = 0;";
+
+const oldSendInstructionBlock =
+ " sendInstruction(instruction) {\n" +
+ " // convert every element in the instruction array to a string. convert null to an empty string\n" +
+ " instruction = instruction.map((element) => {\n" +
+ " if (element === null || element === undefined) {\n" +
+ " return '';\n" +
+ " }\n" +
+ " return String(element);\n" +
+ " });\n" +
+ "\n" +
+ " const instructionString = GuacamoleParser.toInstruction(instruction);\n" +
+ " this.send(instructionString);\n" +
+ " }\n";
+const newSendInstructionBlock =
+ oldSendInstructionBlock +
+ "\n" +
+ " sendArgumentValue(name, value) {\n" +
+ " const stream = this.nextArgumentStreamIndex++;\n" +
+ " this.sendInstruction(['argv', stream, 'text/plain', name]);\n" +
+ " this.sendInstruction(['blob', stream, Buffer.from(String(value ?? ''), 'utf8').toString('base64')]);\n" +
+ " this.sendInstruction(['end', stream]);\n" +
+ " }\n" +
+ "\n" +
+ " sendRequiredArguments(params) {\n" +
+ " params.forEach((name) => {\n" +
+ " this.sendArgumentValue(name, this.connectionSettings[name]);\n" +
+ " });\n" +
+ " }\n";
+
+const oldReadyHandler =
+ ' // Handle "ready" instruction\n' +
+ " if (opcode === 'ready') {";
+const newReadyHandler =
+ " // Handle dynamic argument requests from guacd\n" +
+ " if (opcode === 'required' || opcode === 'require') {\n" +
+ " this.sendRequiredArguments(params);\n" +
+ " return;\n" +
+ " }\n" +
+ "\n" +
+ oldReadyHandler;
+
let patched = false;
-if (!content.includes(newVersionCheck)) {
- if (!content.includes(oldVersionCheck)) {
+if (!guacdClientContent.includes(newVersionCheck)) {
+ if (!guacdClientContent.includes(oldVersionCheck)) {
console.log(
"[patch-guacamole-lite] Version check target not found, skipping",
);
process.exit(0);
}
- content = content.replace(oldVersionCheck, newVersionCheck);
+ guacdClientContent = guacdClientContent.replace(
+ oldVersionCheck,
+ newVersionCheck,
+ );
patched = true;
}
-if (!content.includes(newTimezone)) {
- if (!content.includes(oldTimezone)) {
+if (!guacdClientContent.includes(newTimezone)) {
+ if (!guacdClientContent.includes(oldTimezone)) {
console.log("[patch-guacamole-lite] Timezone target not found, skipping");
process.exit(0);
}
- content = content.replace(oldTimezone, newTimezone);
+ guacdClientContent = guacdClientContent.replace(oldTimezone, newTimezone);
patched = true;
}
-if (!content.includes(newConnect)) {
- if (!content.includes(oldConnect)) {
+if (!guacdClientContent.includes(newConnect)) {
+ if (!guacdClientContent.includes(oldConnect)) {
console.log(
"[patch-guacamole-lite] Connect target not found, skipping name patch",
);
process.exit(0);
}
- content = content.replace(oldConnect, newConnect);
+ guacdClientContent = guacdClientContent.replace(oldConnect, newConnect);
+ patched = true;
+}
+
+if (!guacdClientContent.includes("this.nextArgumentStreamIndex = 0;")) {
+ if (!guacdClientContent.includes(oldSendBuffer)) {
+ console.log(
+ "[patch-guacamole-lite] Argument stream index target not found, skipping",
+ );
+ process.exit(0);
+ }
+ guacdClientContent = guacdClientContent.replace(oldSendBuffer, newSendBuffer);
+ patched = true;
+}
+
+if (!guacdClientContent.includes("sendRequiredArguments(params) {")) {
+ if (!guacdClientContent.includes(oldSendInstructionBlock)) {
+ console.log(
+ "[patch-guacamole-lite] Required argument helper target not found, skipping",
+ );
+ process.exit(0);
+ }
+ guacdClientContent = guacdClientContent.replace(
+ oldSendInstructionBlock,
+ newSendInstructionBlock,
+ );
+ patched = true;
+}
+
+if (
+ !guacdClientContent.includes("opcode === 'required' || opcode === 'require'")
+) {
+ if (!guacdClientContent.includes(oldReadyHandler)) {
+ console.log(
+ "[patch-guacamole-lite] Required opcode target not found, skipping",
+ );
+ process.exit(0);
+ }
+ guacdClientContent = guacdClientContent.replace(
+ oldReadyHandler,
+ newReadyHandler,
+ );
+ patched = true;
+}
+
+// Patch 5: guacamole-lite decrypts token JSON through ASCII/binary strings,
+// which corrupts IV/ciphertext bytes and non-ASCII connection settings such as
+// RDP/VNC passwords with umlauts. Keep the encrypted fields as Buffers and
+// decode the plaintext JSON as UTF-8.
+const oldDecryptBlock =
+ " let encoded = JSON.parse(this.constructor.base64decode(encodedString));\n" +
+ "\n" +
+ " encoded.iv = this.constructor.base64decode(encoded.iv);\n" +
+ " encoded.value = this.constructor.base64decode(encoded.value, 'binary');\n" +
+ "\n" +
+ " const decipher = Crypto.createDecipheriv(this.cypher, this.key, encoded.iv);\n" +
+ "\n" +
+ " let decrypted = decipher.update(encoded.value, 'binary', 'ascii');\n" +
+ " decrypted += decipher.final('ascii');";
+const oldPartiallyPatchedDecryptBlock =
+ " let encoded = JSON.parse(this.constructor.base64decode(encodedString));\n" +
+ "\n" +
+ " encoded.iv = this.constructor.base64decode(encoded.iv);\n" +
+ " encoded.value = this.constructor.base64decode(encoded.value, 'binary');\n" +
+ "\n" +
+ " const decipher = Crypto.createDecipheriv(this.cypher, this.key, encoded.iv);\n" +
+ "\n" +
+ " let decrypted = decipher.update(encoded.value, 'binary', 'utf8');\n" +
+ " decrypted += decipher.final('utf8');";
+const newDecryptBlock =
+ " const encoded = JSON.parse(Buffer.from(encodedString, 'base64').toString('utf8'));\n" +
+ "\n" +
+ " const iv = Buffer.from(encoded.iv, 'base64');\n" +
+ " const value = Buffer.from(encoded.value, 'base64');\n" +
+ "\n" +
+ " const decipher = Crypto.createDecipheriv(this.cypher, this.key, iv);\n" +
+ "\n" +
+ " let decrypted = decipher.update(value, undefined, 'utf8');\n" +
+ " decrypted += decipher.final('utf8');";
+
+if (!cryptContent.includes(newDecryptBlock)) {
+ if (cryptContent.includes(oldDecryptBlock)) {
+ cryptContent = cryptContent.replace(oldDecryptBlock, newDecryptBlock);
+ } else if (cryptContent.includes(oldPartiallyPatchedDecryptBlock)) {
+ cryptContent = cryptContent.replace(
+ oldPartiallyPatchedDecryptBlock,
+ newDecryptBlock,
+ );
+ } else {
+ console.log(
+ "[patch-guacamole-lite] UTF-8 token decrypt target not found, skipping",
+ );
+ process.exit(0);
+ }
patched = true;
}
@@ -79,7 +237,8 @@ if (!patched) {
process.exit(0);
}
-fs.writeFileSync(filePath, content);
+fs.writeFileSync(guacdClientPath, guacdClientContent);
+fs.writeFileSync(cryptPath, cryptContent);
console.log(
- "[patch-guacamole-lite] Patched to support protocol VERSION_1_3_0 and VERSION_1_5_0 with name handshake instruction",
+ "[patch-guacamole-lite] Patched protocol VERSION_1_3_0/1_5_0 support, name handshake, required arguments, and UTF-8 token decrypt",
);
diff --git a/scripts/patch-guacamole-lite.test.ts b/scripts/patch-guacamole-lite.test.ts
new file mode 100644
index 00000000..755d7174
--- /dev/null
+++ b/scripts/patch-guacamole-lite.test.ts
@@ -0,0 +1,23 @@
+import fs from "node:fs";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+
+describe("patch-guacamole-lite", () => {
+ it("handles guacd dynamic argument requests", () => {
+ const guacdClientPath = path.join(
+ process.cwd(),
+ "node_modules",
+ "guacamole-lite",
+ "lib",
+ "GuacdClient.js",
+ );
+
+ const content = fs.readFileSync(guacdClientPath, "utf8");
+
+ expect(content).toContain("sendRequiredArguments(params)");
+ expect(content).toContain("opcode === 'required' || opcode === 'require'");
+ expect(content).toContain("this.sendInstruction(['argv'");
+ expect(content).toContain("this.sendInstruction(['blob'");
+ expect(content).toContain("this.sendInstruction(['end'");
+ });
+});
diff --git a/scripts/publish-youtube.cjs b/scripts/publish-youtube.cjs
index 22ab28fa..a9836857 100644
--- a/scripts/publish-youtube.cjs
+++ b/scripts/publish-youtube.cjs
@@ -50,7 +50,8 @@ async function getVideoStatus(accessToken, videoId) {
`https://www.googleapis.com/youtube/v3/videos?part=status&id=${videoId}`,
{ headers: { Authorization: `Bearer ${accessToken}` } },
);
- if (!res.ok) throw new Error(`videos.list failed (${res.status}): ${await res.text()}`);
+ if (!res.ok)
+ throw new Error(`videos.list failed (${res.status}): ${await res.text()}`);
const json = await res.json();
return json.items?.[0]?.status?.privacyStatus ?? null;
}
diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts
index 2d93745c..c2e0997d 100644
--- a/src/backend/database/database.ts
+++ b/src/backend/database/database.ts
@@ -17,8 +17,11 @@ import rbacRoutes from "./routes/rbac.js";
import openTabsRoutes from "./routes/open-tabs.js";
import userPreferencesRoutes from "./routes/user-preferences.js";
import proxmoxRoutes from "./routes/proxmox.js";
+import termixIdRoutes from "./routes/termix-id.js";
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
+import vaultRoutes from "./routes/vault.js";
+import alertRulesRoutes from "./routes/alert-rules-routes.js";
import { createCorsMiddleware } from "../utils/cors-config.js";
import fs from "fs";
import path from "path";
@@ -1788,8 +1791,11 @@ app.use("/rbac", rbacRoutes);
app.use("/open-tabs", openTabsRoutes);
app.use("/user-preferences", userPreferencesRoutes);
app.use("/proxmox", proxmoxRoutes);
+app.use("/termix-id", termixIdRoutes);
registerAuditLogRoutes(app, authenticateJWT);
registerTailscaleRoutes(app, authenticateJWT);
+app.use("/vault", vaultRoutes);
+app.use("/", alertRulesRoutes);
const frontendDistPaths = [
path.join(__dirname, "../../../dist"),
diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts
index 961062f0..22247c71 100644
--- a/src/backend/database/db/index.ts
+++ b/src/backend/database/db/index.ts
@@ -8,6 +8,7 @@ import { DatabaseFileEncryption } from "../../utils/database-file-encryption.js"
import { SystemCrypto } from "../../utils/system-crypto.js";
import { DatabaseMigration } from "../../utils/database-migration.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
+import { getDefaultGuacdUrl } from "../../utils/guacd-config.js";
const dataDir = process.env.DATA_DIR || "./db/data";
const dbDir = path.resolve(dataDir);
@@ -196,6 +197,22 @@ async function initializeCompleteDatabase(): Promise {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
+ CREATE TABLE IF NOT EXISTS webauthn_credentials (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ credential_id TEXT NOT NULL UNIQUE,
+ public_key TEXT NOT NULL,
+ counter INTEGER NOT NULL DEFAULT 0,
+ device_type TEXT,
+ backed_up INTEGER NOT NULL DEFAULT 0,
+ transports TEXT,
+ user_verification TEXT NOT NULL DEFAULT 'preferred',
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_used_at TEXT,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+ );
+
CREATE TABLE IF NOT EXISTS ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -636,12 +653,11 @@ async function initializeCompleteDatabase(): Promise {
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
.get();
if (!row) {
- const defaultGuacUrl = `${process.env.GUACD_HOST || "localhost"}:${process.env.GUACD_PORT || "4822"}`;
sqlite
.prepare(
"INSERT INTO settings (key, value) VALUES ('guac_url', ?)",
)
- .run(defaultGuacUrl);
+ .run(getDefaultGuacdUrl());
}
} catch (e) {
databaseLogger.warn("Could not initialize guac_url setting", {
@@ -689,12 +705,29 @@ const migrateSchema = () => {
addColumnIfNotExists("user_preferences", "show_host_tags", "INTEGER");
addColumnIfNotExists("user_preferences", "host_tray_on_click", "INTEGER");
addColumnIfNotExists("user_preferences", "pin_app_rail", "INTEGER");
+ addColumnIfNotExists(
+ "user_preferences",
+ "expand_app_rail_on_hover",
+ "INTEGER",
+ );
addColumnIfNotExists("user_preferences", "folders_collapsed", "INTEGER");
addColumnIfNotExists("user_preferences", "confirm_snippet_execution", "INTEGER");
addColumnIfNotExists("user_preferences", "disable_update_check", "INTEGER");
addColumnIfNotExists("user_preferences", "confirm_tab_close", "INTEGER");
addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT");
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
+ addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
+
+ sqlite.exec(`
+ CREATE TABLE IF NOT EXISTS dashboard_service_links (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ label TEXT NOT NULL,
+ url TEXT NOT NULL,
+ "order" INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )
+ `);
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
@@ -714,6 +747,24 @@ const migrateSchema = () => {
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
+ sqlite.exec(`
+ CREATE TABLE IF NOT EXISTS webauthn_credentials (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ credential_id TEXT NOT NULL UNIQUE,
+ public_key TEXT NOT NULL,
+ counter INTEGER NOT NULL DEFAULT 0,
+ device_type TEXT,
+ backed_up INTEGER NOT NULL DEFAULT 0,
+ transports TEXT,
+ user_verification TEXT NOT NULL DEFAULT 'preferred',
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_used_at TEXT,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+ )
+ `);
+
addColumnIfNotExists("ssh_data", "name", "TEXT");
addColumnIfNotExists("ssh_data", "folder", "TEXT");
addColumnIfNotExists("ssh_data", "tags", "TEXT");
@@ -784,6 +835,11 @@ const migrateSchema = () => {
"override_credential_username",
"INTEGER",
);
+ addColumnIfNotExists(
+ "ssh_data",
+ "vault_profile_id",
+ "INTEGER REFERENCES vault_profiles(id) ON DELETE SET NULL",
+ );
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
@@ -990,6 +1046,111 @@ const migrateSchema = () => {
}
}
+ try {
+ sqlite.prepare("SELECT id FROM termix_identities LIMIT 1").get();
+ } catch {
+ try {
+ sqlite.exec(`
+ CREATE TABLE IF NOT EXISTS termix_identities (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL UNIQUE,
+ handle TEXT NOT NULL UNIQUE,
+ description 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 termix_identities table", {
+ operation: "schema_migration",
+ error: createError,
+ });
+ }
+ }
+
+ // Enforce one-Termix-ID-per-user on databases where the table predates the
+ // UNIQUE(user_id) constraint above.
+ try {
+ sqlite.exec(
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_termix_identities_user ON termix_identities(user_id)",
+ );
+ } catch (indexError) {
+ databaseLogger.warn("Failed to create termix_identities user_id unique index", {
+ operation: "schema_migration",
+ error: indexError,
+ });
+ }
+
+ try {
+ sqlite.prepare("SELECT id FROM termix_identity_keys LIMIT 1").get();
+ } catch {
+ try {
+ sqlite.exec(`
+ CREATE TABLE IF NOT EXISTS termix_identity_keys (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ identity_id INTEGER NOT NULL,
+ user_id TEXT NOT NULL,
+ public_key TEXT NOT NULL,
+ key_type TEXT NOT NULL,
+ algorithm TEXT NOT NULL,
+ label TEXT,
+ comment TEXT,
+ source TEXT NOT NULL DEFAULT 'manual',
+ credential_id INTEGER,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (identity_id) REFERENCES termix_identities (id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
+ FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE SET NULL
+ );
+ `);
+ } catch (createError) {
+ databaseLogger.warn("Failed to create termix_identity_keys table", {
+ operation: "schema_migration",
+ error: createError,
+ });
+ }
+ }
+
+ // The public resolver fetches keys by identity_id on every request; index it.
+ try {
+ sqlite.exec(
+ "CREATE INDEX IF NOT EXISTS idx_termix_identity_keys_identity ON termix_identity_keys(identity_id)",
+ );
+ } catch (indexError) {
+ databaseLogger.warn("Failed to create termix_identity_keys identity index", {
+ operation: "schema_migration",
+ error: indexError,
+ });
+ }
+
+ try {
+ sqlite.prepare("SELECT id FROM termix_identity_ca LIMIT 1").get();
+ } catch {
+ try {
+ sqlite.exec(`
+ CREATE TABLE IF NOT EXISTS termix_identity_ca (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ identity_id INTEGER NOT NULL UNIQUE,
+ user_id TEXT NOT NULL,
+ public_key TEXT NOT NULL,
+ private_key TEXT NOT NULL,
+ validity_days INTEGER NOT NULL DEFAULT 90,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (identity_id) REFERENCES termix_identities (id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+ );
+ `);
+ } catch (createError) {
+ databaseLogger.warn("Failed to create termix_identity_ca table", {
+ operation: "schema_migration",
+ error: createError,
+ });
+ }
+ }
+
try {
sqlite.prepare("SELECT id FROM c2s_tunnel_presets LIMIT 1").get();
} catch {
@@ -1470,6 +1631,67 @@ const migrateSchema = () => {
}
}
+ try {
+ sqlite.prepare("SELECT id FROM vault_profiles LIMIT 1").get();
+ } catch {
+ try {
+ sqlite.exec(`
+ CREATE TABLE IF NOT EXISTS vault_profiles (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ description TEXT,
+ folder TEXT,
+ tags TEXT,
+ vault_addr TEXT NOT NULL,
+ vault_namespace TEXT,
+ oidc_mount TEXT,
+ oidc_role TEXT,
+ ssh_mount TEXT,
+ ssh_role TEXT NOT NULL,
+ valid_principals TEXT,
+ key_type TEXT,
+ shared INTEGER NOT NULL DEFAULT 0,
+ 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 vault_profiles table", {
+ operation: "schema_migration",
+ error: createError,
+ });
+ }
+ }
+
+ try {
+ sqlite.prepare("SELECT id FROM vault_tokens LIMIT 1").get();
+ } catch {
+ try {
+ sqlite.exec(`
+ CREATE TABLE IF NOT EXISTS vault_tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL,
+ profile_id INTEGER NOT NULL,
+ ssh_cert TEXT NOT NULL,
+ private_key TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ expires_at TEXT NOT NULL,
+ last_used TEXT,
+ UNIQUE(user_id, profile_id),
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
+ FOREIGN KEY (profile_id) REFERENCES vault_profiles (id) ON DELETE CASCADE
+ );
+ `);
+ } catch (createError) {
+ databaseLogger.warn("Failed to create vault_tokens table", {
+ operation: "schema_migration",
+ error: createError,
+ });
+ }
+ }
+
try {
sqlite.prepare("SELECT id FROM api_keys LIMIT 1").get();
} catch {
@@ -1711,6 +1933,197 @@ const migrateSchema = () => {
});
}
+ // --- metrics-history begin ---
+ try {
+ sqlite.prepare("SELECT id FROM host_metrics_history LIMIT 1").get();
+ } catch {
+ try {
+ sqlite.exec(`
+ CREATE TABLE IF NOT EXISTS host_metrics_history (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
+ ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ cpu_percent REAL,
+ mem_percent REAL,
+ disk_percent REAL,
+ net_rx_bytes INTEGER,
+ net_tx_bytes INTEGER
+ );
+ CREATE INDEX IF NOT EXISTS idx_host_metrics_history_host_ts
+ ON host_metrics_history (host_id, ts DESC);
+ `);
+ } catch (createError) {
+ databaseLogger.warn("Failed to create host_metrics_history table", {
+ operation: "schema_migration",
+ error: createError,
+ });
+ }
+ }
+ // --- metrics-history end ---
+
+ // --- alerts begin ---
+ try {
+ sqlite.prepare("SELECT id FROM alert_rules LIMIT 1").get();
+ } catch {
+ try {
+ sqlite.exec(`
+ CREATE TABLE IF NOT EXISTS alert_rules (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ host_id INTEGER REFERENCES ssh_data(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ trigger_type TEXT NOT NULL,
+ threshold_value REAL,
+ threshold_duration_seconds INTEGER,
+ cooldown_minutes INTEGER NOT NULL DEFAULT 15,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+ `);
+ } catch (createError) {
+ databaseLogger.warn("Failed to create alert_rules table", {
+ operation: "schema_migration",
+ error: createError,
+ });
+ }
+ }
+
+ try {
+ sqlite.prepare("SELECT id FROM notification_channels LIMIT 1").get();
+ } catch {
+ try {
+ sqlite.exec(`
+ CREATE TABLE IF NOT EXISTS notification_channels (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ type TEXT NOT NULL,
+ config TEXT NOT NULL,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+ `);
+ } catch (createError) {
+ databaseLogger.warn("Failed to create notification_channels table", {
+ operation: "schema_migration",
+ error: createError,
+ });
+ }
+ }
+
+ try {
+ sqlite.prepare("SELECT id FROM alert_rule_channels LIMIT 1").get();
+ } catch {
+ try {
+ sqlite.exec(`
+ CREATE TABLE IF NOT EXISTS alert_rule_channels (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ rule_id INTEGER NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
+ channel_id INTEGER NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE
+ );
+ `);
+ } catch (createError) {
+ databaseLogger.warn("Failed to create alert_rule_channels table", {
+ operation: "schema_migration",
+ error: createError,
+ });
+ }
+ }
+
+ try {
+ sqlite.prepare("SELECT id FROM alert_firings LIMIT 1").get();
+ } catch {
+ try {
+ sqlite.exec(`
+ CREATE TABLE IF NOT EXISTS alert_firings (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ rule_id INTEGER NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
+ host_id INTEGER NOT NULL,
+ host_name TEXT NOT NULL,
+ fired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ resolved_at TEXT,
+ value REAL,
+ message TEXT NOT NULL,
+ severity TEXT NOT NULL DEFAULT 'warning',
+ acknowledged INTEGER NOT NULL DEFAULT 0
+ );
+ CREATE INDEX IF NOT EXISTS idx_alert_firings_user_fired
+ ON alert_firings (user_id, fired_at DESC);
+ `);
+ } catch (createError) {
+ databaseLogger.warn("Failed to create alert_firings table", {
+ operation: "schema_migration",
+ error: createError,
+ });
+ }
+ }
+ // --- alerts end ---
+
+ // Seed default metrics history retention setting
+ try {
+ const retentionRow = sqlite
+ .prepare("SELECT value FROM settings WHERE key = 'metrics_history_retention_days'")
+ .get();
+ if (!retentionRow) {
+ sqlite
+ .prepare("INSERT INTO settings (key, value) VALUES ('metrics_history_retention_days', '7')")
+ .run();
+ }
+ } catch (e) {
+ databaseLogger.warn("Could not initialize metrics_history_retention_days setting", {
+ operation: "schema_migration",
+ error: e,
+ });
+ }
+
+ // --- homepage begin ---
+ try {
+ sqlite.prepare("SELECT id FROM homepage_items LIMIT 1").get();
+ } catch {
+ try {
+ sqlite.exec(`
+ CREATE TABLE IF NOT EXISTS homepage_items (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ type_id TEXT NOT NULL,
+ title TEXT,
+ config TEXT NOT NULL DEFAULT '{}',
+ folder_id INTEGER,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+ `);
+ } catch (createError) {
+ databaseLogger.warn("Failed to create homepage_items table", {
+ operation: "schema_migration",
+ error: createError,
+ });
+ }
+ }
+
+ try {
+ sqlite.prepare("SELECT id FROM homepage_layouts LIMIT 1").get();
+ } catch {
+ try {
+ sqlite.exec(`
+ CREATE TABLE IF NOT EXISTS homepage_layouts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
+ layout TEXT NOT NULL DEFAULT '{}',
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+ `);
+ } catch (createError) {
+ databaseLogger.warn("Failed to create homepage_layouts table", {
+ operation: "schema_migration",
+ error: createError,
+ });
+ }
+ }
+ // --- homepage end ---
+
databaseLogger.success("Schema migration completed", {
operation: "schema_migration",
});
diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts
index 4e72535d..fabad70f 100644
--- a/src/backend/database/db/schema.ts
+++ b/src/backend/database/db/schema.ts
@@ -1,4 +1,4 @@
-import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
+import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
export const users = sqliteTable("users", {
@@ -80,6 +80,25 @@ export const trustedDevices = sqliteTable("trusted_devices", {
.default(sql`CURRENT_TIMESTAMP`),
});
+export const webauthnCredentials = sqliteTable("webauthn_credentials", {
+ id: text("id").primaryKey(),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ name: text("name").notNull(),
+ credentialId: text("credential_id").notNull(),
+ publicKey: text("public_key").notNull(),
+ counter: integer("counter").notNull().default(0),
+ deviceType: text("device_type"),
+ backedUp: integer("backed_up", { mode: "boolean" }).notNull().default(false),
+ transports: text("transports"),
+ userVerification: text("user_verification").notNull().default("preferred"),
+ createdAt: text("created_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+ lastUsedAt: text("last_used_at"),
+});
+
export const hosts = sqliteTable("ssh_data", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
@@ -111,6 +130,13 @@ export const hosts = sqliteTable("ssh_data", {
overrideCredentialUsername: integer("override_credential_username", {
mode: "boolean",
}),
+ // When authType is "vault", the host authenticates via a Vault SSH signer
+ // profile (shared settings, no secrets). The signing certificate is obtained
+ // per-user at connect time via an interactive Vault OIDC flow.
+ vaultProfileId: integer("vault_profile_id").references(
+ () => vaultProfiles.id,
+ { onDelete: "set null" },
+ ),
enableTerminal: integer("enable_terminal", { mode: "boolean" })
.notNull()
.default(true),
@@ -661,6 +687,63 @@ export const opksshTokens = sqliteTable("opkssh_tokens", {
lastUsed: text("last_used"),
});
+// Vault SSH signer profiles. These hold ONLY non-secret connection settings and
+// are intended to be shared across users (shared === true makes a profile
+// visible to every user on the server). Each user authenticates to Vault via an
+// interactive OIDC flow at connect time; no tokens or keys are stored here.
+export const vaultProfiles = sqliteTable("vault_profiles", {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ name: text("name").notNull(),
+ description: text("description"),
+ folder: text("folder"),
+ tags: text("tags"),
+ // Vault server connection (non-secret)
+ vaultAddr: text("vault_addr").notNull(),
+ vaultNamespace: text("vault_namespace"),
+ // OIDC auth method mount + role used to obtain a Vault token interactively
+ oidcMount: text("oidc_mount"),
+ oidcRole: text("oidc_role"),
+ // SSH secrets engine mount + signer role used to sign the ephemeral key
+ sshMount: text("ssh_mount"),
+ sshRole: text("ssh_role").notNull(),
+ validPrincipals: text("valid_principals"),
+ // Ephemeral keypair algorithm to generate per connection
+ keyType: text("key_type"),
+ // When true the profile is visible/usable by all users on the server
+ shared: integer("shared", { mode: "boolean" }).notNull().default(false),
+ createdAt: text("created_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+ updatedAt: text("updated_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+});
+
+// Per-user cache of the ephemeral SSH private key + Vault-signed certificate.
+// Transient: rows live only until the certificate expires. Secret fields are
+// encrypted under the user's data-encryption key (see field-crypto.ts).
+export const vaultTokens = sqliteTable("vault_tokens", {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ profileId: integer("profile_id")
+ .notNull()
+ .references(() => vaultProfiles.id, { onDelete: "cascade" }),
+
+ sshCert: text("ssh_cert", { length: 8192 }).notNull(),
+ privateKey: text("private_key", { length: 8192 }).notNull(),
+
+ createdAt: text("created_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+ expiresAt: text("expires_at").notNull(),
+ lastUsed: text("last_used"),
+});
+
export const apiKeys = sqliteTable("api_keys", {
id: text("id").primaryKey(),
userId: text("user_id")
@@ -710,6 +793,9 @@ export const userPreferences = sqliteTable("user_preferences", {
showHostTags: integer("show_host_tags", { mode: "boolean" }),
hostTrayOnClick: integer("host_tray_on_click", { mode: "boolean" }),
pinAppRail: integer("pin_app_rail", { mode: "boolean" }),
+ expandAppRailOnHover: integer("expand_app_rail_on_hover", {
+ mode: "boolean",
+ }),
foldersCollapsed: integer("folders_collapsed", { mode: "boolean" }),
confirmSnippetExecution: integer("confirm_snippet_execution", { mode: "boolean" }),
disableUpdateCheck: integer("disable_update_check", { mode: "boolean" }),
@@ -788,6 +874,79 @@ export const dashboardServiceLinks = sqliteTable("dashboard_service_links", {
.default(sql`CURRENT_TIMESTAMP`),
});
+// --- termix-id begin ---
+// A user claims a unique public handle. Their published SSH public keys are
+// served at an unauthenticated resolver endpoint in authorized_keys format,
+// so any server can be provisioned with `curl /termix-id/u/ >> ~/.ssh/authorized_keys`.
+export const termixIdentities = sqliteTable("termix_identities", {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ // One Termix ID per user — enforced in schema, not just in code.
+ userId: text("user_id")
+ .notNull()
+ .unique()
+ .references(() => users.id, { onDelete: "cascade" }),
+ handle: text("handle").notNull().unique(),
+ description: text("description"),
+ createdAt: text("created_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+ updatedAt: text("updated_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+});
+
+export const termixIdentityKeys = sqliteTable("termix_identity_keys", {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ identityId: integer("identity_id")
+ .notNull()
+ .references(() => termixIdentities.id, { onDelete: "cascade" }),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ // Public keys are non-secret, so they are stored in plaintext (no field-level
+ // encryption). This is what lets the unauthenticated resolver serve them.
+ publicKey: text("public_key", { length: 8192 }).notNull(),
+ // Raw algorithm token (e.g. "ssh-ed25519"), and a normalized group used for
+ // the / resolver filter (RSA / ED25519 / ECDSA / ...).
+ keyType: text("key_type").notNull(),
+ algorithm: text("algorithm").notNull(),
+ label: text("label"),
+ comment: text("comment"),
+ // "manual" (pasted) or "credential" (imported from an ssh_credentials entry).
+ source: text("source").notNull().default("manual"),
+ credentialId: integer("credential_id").references(() => sshCredentials.id, {
+ onDelete: "set null",
+ }),
+ enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
+ createdAt: text("created_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+});
+// Per-identity certificate authority. Servers that trust this CA (via
+// TrustedUserCAKeys / @cert-authority) accept any user certificate it signs,
+// giving central revocation (rotate the CA) and expiry (cert validity).
+export const termixIdentityCa = sqliteTable("termix_identity_ca", {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ identityId: integer("identity_id")
+ .notNull()
+ .unique()
+ .references(() => termixIdentities.id, { onDelete: "cascade" }),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ // CA public key (plaintext — it is published); CA private key is field-encrypted.
+ publicKey: text("public_key", { length: 4096 }).notNull(),
+ privateKey: text("private_key", { length: 8192 }).notNull(),
+ validityDays: integer("validity_days").notNull().default(90),
+ createdAt: text("created_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+ updatedAt: text("updated_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+});
+// --- termix-id end ---
+
// --- tmux-monitor begin ---
export const tmuxSessionTags = sqliteTable("tmux_session_tags", {
id: integer("id").primaryKey({ autoIncrement: true }),
@@ -804,3 +963,118 @@ export const tmuxSessionTags = sqliteTable("tmux_session_tags", {
.default(sql`CURRENT_TIMESTAMP`),
});
// --- tmux-monitor end ---
+
+// --- metrics-history begin ---
+export const hostMetricsHistory = sqliteTable("host_metrics_history", {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ hostId: integer("host_id")
+ .notNull()
+ .references(() => hosts.id, { onDelete: "cascade" }),
+ ts: text("ts")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+ cpuPercent: real("cpu_percent"),
+ memPercent: real("mem_percent"),
+ diskPercent: real("disk_percent"),
+ netRxBytes: integer("net_rx_bytes"),
+ netTxBytes: integer("net_tx_bytes"),
+});
+// --- metrics-history end ---
+
+// --- alerts begin ---
+export const alertRules = sqliteTable("alert_rules", {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ hostId: integer("host_id").references(() => hosts.id, { onDelete: "cascade" }),
+ name: text("name").notNull(),
+ enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
+ triggerType: text("trigger_type").notNull(),
+ thresholdValue: real("threshold_value"),
+ thresholdDurationSeconds: integer("threshold_duration_seconds"),
+ cooldownMinutes: integer("cooldown_minutes").notNull().default(15),
+ createdAt: text("created_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+ updatedAt: text("updated_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+});
+
+export const notificationChannels = sqliteTable("notification_channels", {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ name: text("name").notNull(),
+ type: text("type").notNull(),
+ config: text("config").notNull(),
+ enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
+ createdAt: text("created_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+});
+
+export const alertRuleChannels = sqliteTable("alert_rule_channels", {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ ruleId: integer("rule_id")
+ .notNull()
+ .references(() => alertRules.id, { onDelete: "cascade" }),
+ channelId: integer("channel_id")
+ .notNull()
+ .references(() => notificationChannels.id, { onDelete: "cascade" }),
+});
+
+export const alertFirings = sqliteTable("alert_firings", {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ ruleId: integer("rule_id")
+ .notNull()
+ .references(() => alertRules.id, { onDelete: "cascade" }),
+ hostId: integer("host_id").notNull(),
+ hostName: text("host_name").notNull(),
+ firedAt: text("fired_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+ resolvedAt: text("resolved_at"),
+ value: real("value"),
+ message: text("message").notNull(),
+ severity: text("severity").notNull().default("warning"),
+ acknowledged: integer("acknowledged", { mode: "boolean" }).notNull().default(false),
+});
+// --- alerts end ---
+
+// --- homepage begin ---
+export const homepageItems = sqliteTable("homepage_items", {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ typeId: text("type_id").notNull(),
+ title: text("title"),
+ config: text("config").notNull().default("{}"),
+ folderId: integer("folder_id"),
+ createdAt: text("created_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+ updatedAt: text("updated_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+});
+
+export const homepageLayouts = sqliteTable("homepage_layouts", {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ userId: text("user_id")
+ .notNull()
+ .unique()
+ .references(() => users.id, { onDelete: "cascade" }),
+ // JSON: { entries: HomepageLayoutEntry[], pan: {x,y}, zoom: number }
+ layout: text("layout").notNull().default("{}"),
+ updatedAt: text("updated_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+});
+// --- homepage end ---
diff --git a/src/backend/database/routes/acme-ssl-routes.ts b/src/backend/database/routes/acme-ssl-routes.ts
index 0fcf286b..8d55f65e 100644
--- a/src/backend/database/routes/acme-ssl-routes.ts
+++ b/src/backend/database/routes/acme-ssl-routes.ts
@@ -12,6 +12,10 @@ import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
const DATA_DIR = process.env.DATA_DIR || "./db/data";
const SSL_DIR = path.join(DATA_DIR, "ssl");
const ACME_WEBROOT = path.join(DATA_DIR, "acme-webroot");
+const CERTBOT_DIR = path.join(DATA_DIR, "certbot");
+const CERTBOT_CONFIG_DIR = path.join(CERTBOT_DIR, "config");
+const CERTBOT_WORK_DIR = path.join(CERTBOT_DIR, "work");
+const CERTBOT_LOGS_DIR = path.join(CERTBOT_DIR, "logs");
const CLOUDFLARE_CREDENTIALS_FILE = path.join(
DATA_DIR,
"ssl",
@@ -270,6 +274,15 @@ export function registerAcmeSSLRoutes(
await fs.mkdir(SSL_DIR, { recursive: true });
await fs.mkdir(ACME_WEBROOT, { recursive: true });
+ await fs.mkdir(CERTBOT_CONFIG_DIR, { recursive: true });
+ await fs.mkdir(CERTBOT_WORK_DIR, { recursive: true });
+ await fs.mkdir(CERTBOT_LOGS_DIR, { recursive: true });
+
+ const certbotDirFlags = [
+ `--config-dir "${CERTBOT_CONFIG_DIR}"`,
+ `--work-dir "${CERTBOT_WORK_DIR}"`,
+ `--logs-dir "${CERTBOT_LOGS_DIR}"`,
+ ].join(" ");
let certbotCmd: string;
@@ -304,6 +317,7 @@ export function registerAcmeSSLRoutes(
`"${email}"`,
"--cert-name",
"termix",
+ certbotDirFlags,
].join(" ");
} else {
certbotCmd = [
@@ -320,6 +334,7 @@ export function registerAcmeSSLRoutes(
`"${email}"`,
"--cert-name",
"termix",
+ certbotDirFlags,
].join(" ");
}
@@ -331,7 +346,7 @@ export function registerAcmeSSLRoutes(
execSync(certbotCmd, { stdio: "pipe", timeout: 120000 });
- const liveDir = `/etc/letsencrypt/live/termix`;
+ const liveDir = path.join(CERTBOT_CONFIG_DIR, "live", "termix");
const fullchainSrc = path.join(liveDir, "fullchain.pem");
const privkeySrc = path.join(liveDir, "privkey.pem");
const certDest = path.join(SSL_DIR, "termix.crt");
diff --git a/src/backend/database/routes/alert-rules-routes.ts b/src/backend/database/routes/alert-rules-routes.ts
new file mode 100644
index 00000000..10b8d7e0
--- /dev/null
+++ b/src/backend/database/routes/alert-rules-routes.ts
@@ -0,0 +1,660 @@
+import express, { type Request, type Response } from "express";
+import type { AuthenticatedRequest } from "../../../types/index.js";
+import { getDb } from "../db/index.js";
+import { AuthManager } from "../../utils/auth-manager.js";
+import { DatabaseSaveTrigger } from "../db/index.js";
+import { databaseLogger } from "../../utils/logger.js";
+import { sendWebhook, sendNtfy } from "../../utils/notification-sender.js";
+
+const router = express.Router();
+const authManager = AuthManager.getInstance();
+const authenticateJWT = authManager.createAuthMiddleware();
+
+const VALID_TRIGGER_TYPES = new Set([
+ "host_offline",
+ "host_online",
+ "cpu_threshold",
+ "memory_threshold",
+ "disk_threshold",
+ "health_check_failure",
+ "health_check_recovery",
+ "user_login",
+]);
+
+router.use(authenticateJWT);
+
+// ---- Notification Channels ----
+
+/**
+ * @openapi
+ * /notification-channels:
+ * get:
+ * summary: List notification channels for the current user
+ * tags:
+ * - Alerts
+ * responses:
+ * 200:
+ * description: List of notification channels.
+ */
+router.get("/notification-channels", (req, res) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ try {
+ const rows = getDb()
+ .$client.prepare(
+ "SELECT * FROM notification_channels WHERE user_id = ? ORDER BY id ASC",
+ )
+ .all(userId);
+ res.json(rows);
+ } catch (err) {
+ databaseLogger.error("Failed to list notification channels", {
+ operation: "list_channels",
+ error: err,
+ });
+ res.status(500).json({ error: "Failed to list channels" });
+ }
+});
+
+/**
+ * @openapi
+ * /notification-channels:
+ * post:
+ * summary: Create a notification channel
+ * tags:
+ * - Alerts
+ */
+router.post("/notification-channels", (req, res) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ const { name, type, config, enabled } = req.body as {
+ name: string;
+ type: string;
+ config: unknown;
+ enabled?: boolean;
+ };
+
+ if (!name || typeof name !== "string" || !name.trim()) {
+ return res.status(400).json({ error: "name is required" });
+ }
+ if (type !== "webhook" && type !== "ntfy") {
+ return res.status(400).json({ error: "type must be 'webhook' or 'ntfy'" });
+ }
+ if (!config || typeof config !== "object") {
+ return res.status(400).json({ error: "config is required" });
+ }
+ if (type === "ntfy") {
+ const c = config as Record;
+ if (!c.url || typeof c.url !== "string")
+ return res.status(400).json({ error: "ntfy config requires url" });
+ if (!c.topic || typeof c.topic !== "string")
+ return res.status(400).json({ error: "ntfy config requires topic" });
+ }
+ if (type === "webhook") {
+ const c = config as Record;
+ if (!c.url || typeof c.url !== "string")
+ return res.status(400).json({ error: "webhook config requires url" });
+ }
+
+ try {
+ const result = getDb()
+ .$client.prepare(
+ `INSERT INTO notification_channels (user_id, name, type, config, enabled)
+ VALUES (?, ?, ?, ?, ?)`,
+ )
+ .run(
+ userId,
+ name.trim(),
+ type,
+ JSON.stringify(config),
+ enabled !== false ? 1 : 0,
+ );
+ const row = getDb()
+ .$client.prepare("SELECT * FROM notification_channels WHERE id = ?")
+ .get(result.lastInsertRowid);
+ DatabaseSaveTrigger.triggerSave("notification_channel_created");
+ res.status(201).json(row);
+ } catch (err) {
+ databaseLogger.error("Failed to create notification channel", {
+ operation: "create_channel",
+ error: err,
+ });
+ res.status(500).json({ error: "Failed to create channel" });
+ }
+});
+
+/**
+ * @openapi
+ * /notification-channels/{id}:
+ * put:
+ * summary: Update a notification channel
+ * tags:
+ * - Alerts
+ */
+router.put("/notification-channels/:id", (req: Request, res: Response) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ const channelId = Number(req.params.id);
+ const { name, type, config, enabled } = req.body as {
+ name?: string;
+ type?: string;
+ config?: unknown;
+ enabled?: boolean;
+ };
+
+ const existing = getDb()
+ .$client.prepare(
+ "SELECT id FROM notification_channels WHERE id = ? AND user_id = ?",
+ )
+ .get(channelId, userId);
+ if (!existing) return res.status(404).json({ error: "Channel not found" });
+
+ if (type && type !== "webhook" && type !== "ntfy") {
+ return res.status(400).json({ error: "type must be 'webhook' or 'ntfy'" });
+ }
+
+ try {
+ const updates: string[] = [];
+ const params: unknown[] = [];
+ if (name !== undefined) {
+ updates.push("name = ?");
+ params.push(name.trim());
+ }
+ if (type !== undefined) {
+ updates.push("type = ?");
+ params.push(type);
+ }
+ if (config !== undefined) {
+ updates.push("config = ?");
+ params.push(JSON.stringify(config));
+ }
+ if (enabled !== undefined) {
+ updates.push("enabled = ?");
+ params.push(enabled ? 1 : 0);
+ }
+
+ if (updates.length === 0) return res.json({ success: true });
+
+ params.push(channelId, userId);
+ getDb()
+ .$client.prepare(
+ `UPDATE notification_channels SET ${updates.join(", ")} WHERE id = ? AND user_id = ?`,
+ )
+ .run(...params);
+
+ const row = getDb()
+ .$client.prepare("SELECT * FROM notification_channels WHERE id = ?")
+ .get(channelId);
+ DatabaseSaveTrigger.triggerSave("notification_channel_updated");
+ res.json(row);
+ } catch (err) {
+ databaseLogger.error("Failed to update notification channel", {
+ operation: "update_channel",
+ error: err,
+ });
+ res.status(500).json({ error: "Failed to update channel" });
+ }
+});
+
+/**
+ * @openapi
+ * /notification-channels/{id}:
+ * delete:
+ * summary: Delete a notification channel
+ * tags:
+ * - Alerts
+ */
+router.delete("/notification-channels/:id", (req: Request, res: Response) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ const channelId = Number(req.params.id);
+ const existing = getDb()
+ .$client.prepare(
+ "SELECT id FROM notification_channels WHERE id = ? AND user_id = ?",
+ )
+ .get(channelId, userId);
+ if (!existing) return res.status(404).json({ error: "Channel not found" });
+ getDb()
+ .$client.prepare("DELETE FROM notification_channels WHERE id = ?")
+ .run(channelId);
+ DatabaseSaveTrigger.triggerSave("notification_channel_deleted");
+ res.json({ success: true });
+});
+
+/**
+ * @openapi
+ * /notification-channels/{id}/test:
+ * post:
+ * summary: Send a test notification
+ * tags:
+ * - Alerts
+ */
+router.post(
+ "/notification-channels/:id/test",
+ async (req: Request, res: Response) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ const channelId = Number(req.params.id);
+ const row = getDb()
+ .$client.prepare(
+ "SELECT * FROM notification_channels WHERE id = ? AND user_id = ?",
+ )
+ .get(channelId, userId) as { type: string; config: string } | undefined;
+ if (!row) return res.status(404).json({ error: "Channel not found" });
+
+ const testPayload = {
+ hostName: "Test Host",
+ hostId: 0,
+ triggerType: "test",
+ message: "This is a test notification from Termix",
+ severity: "info" as const,
+ timestamp: new Date().toISOString(),
+ ruleId: 0,
+ ruleName: "Test",
+ };
+
+ try {
+ let config: Record;
+ try {
+ config = JSON.parse(row.config) as Record;
+ } catch {
+ return res
+ .status(400)
+ .json({ success: false, error: "Invalid channel config" });
+ }
+
+ if (row.type === "webhook") {
+ await sendWebhook(
+ config as unknown as Parameters[0],
+ testPayload,
+ );
+ } else if (row.type === "ntfy") {
+ await sendNtfy(
+ config as unknown as Parameters[0],
+ testPayload,
+ );
+ }
+ res.json({ success: true });
+ } catch (err) {
+ res.json({
+ success: false,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ },
+);
+
+// ---- Alert Rules ----
+
+/**
+ * @openapi
+ * /alert-rules:
+ * get:
+ * summary: List alert rules for the current user
+ * tags:
+ * - Alerts
+ */
+router.get("/alert-rules", (req, res) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ try {
+ const rules = getDb()
+ .$client.prepare(
+ "SELECT * FROM alert_rules WHERE user_id = ? ORDER BY id ASC",
+ )
+ .all(userId) as Array<{ id: number }>;
+
+ const channelMap = new Map();
+ for (const rule of rules) {
+ const channels = getDb()
+ .$client.prepare(
+ "SELECT channel_id FROM alert_rule_channels WHERE rule_id = ?",
+ )
+ .all(rule.id) as Array<{ channel_id: number }>;
+ channelMap.set(
+ rule.id,
+ channels.map((c) => c.channel_id),
+ );
+ }
+
+ const result = rules.map((r) => ({
+ ...r,
+ channels: channelMap.get(r.id) ?? [],
+ }));
+ res.json(result);
+ } catch (err) {
+ databaseLogger.error("Failed to list alert rules", {
+ operation: "list_alert_rules",
+ error: err,
+ });
+ res.status(500).json({ error: "Failed to list alert rules" });
+ }
+});
+
+/**
+ * @openapi
+ * /alert-rules:
+ * post:
+ * summary: Create an alert rule
+ * tags:
+ * - Alerts
+ */
+router.post("/alert-rules", (req, res) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ const {
+ name,
+ hostId,
+ enabled,
+ triggerType,
+ thresholdValue,
+ thresholdDurationSeconds,
+ cooldownMinutes,
+ channels = [],
+ } = req.body as {
+ name: string;
+ hostId?: number | null;
+ enabled?: boolean;
+ triggerType: string;
+ thresholdValue?: number | null;
+ thresholdDurationSeconds?: number | null;
+ cooldownMinutes?: number;
+ channels?: number[];
+ };
+
+ if (!name || typeof name !== "string" || !name.trim()) {
+ return res.status(400).json({ error: "name is required" });
+ }
+ if (!VALID_TRIGGER_TYPES.has(triggerType)) {
+ return res.status(400).json({ error: "Invalid triggerType" });
+ }
+ if (thresholdValue != null && (thresholdValue < 0 || thresholdValue > 100)) {
+ return res
+ .status(400)
+ .json({ error: "thresholdValue must be between 0 and 100" });
+ }
+ if (thresholdDurationSeconds != null && thresholdDurationSeconds < 0) {
+ return res
+ .status(400)
+ .json({ error: "thresholdDurationSeconds must be >= 0" });
+ }
+
+ try {
+ const now = new Date().toISOString();
+ const result = getDb()
+ .$client.prepare(
+ `INSERT INTO alert_rules
+ (user_id, host_id, name, enabled, trigger_type, threshold_value,
+ threshold_duration_seconds, cooldown_minutes, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ )
+ .run(
+ userId,
+ hostId ?? null,
+ name.trim(),
+ enabled !== false ? 1 : 0,
+ triggerType,
+ thresholdValue ?? null,
+ thresholdDurationSeconds ?? null,
+ cooldownMinutes ?? 15,
+ now,
+ now,
+ );
+ const ruleId = result.lastInsertRowid as number;
+
+ for (const channelId of channels) {
+ const owned = getDb()
+ .$client.prepare(
+ "SELECT id FROM notification_channels WHERE id = ? AND user_id = ?",
+ )
+ .get(channelId, userId);
+ if (!owned) continue;
+ getDb()
+ .$client.prepare(
+ "INSERT OR IGNORE INTO alert_rule_channels (rule_id, channel_id) VALUES (?, ?)",
+ )
+ .run(ruleId, channelId);
+ }
+
+ const row = getDb()
+ .$client.prepare("SELECT * FROM alert_rules WHERE id = ?")
+ .get(ruleId) as Record;
+ DatabaseSaveTrigger.triggerSave("alert_rule_created");
+ res.status(201).json({ ...row, channels });
+ } catch (err) {
+ databaseLogger.error("Failed to create alert rule", {
+ operation: "create_alert_rule",
+ error: err,
+ });
+ res.status(500).json({ error: "Failed to create alert rule" });
+ }
+});
+
+/**
+ * @openapi
+ * /alert-rules/{id}:
+ * put:
+ * summary: Update an alert rule
+ * tags:
+ * - Alerts
+ */
+router.put("/alert-rules/:id", (req: Request, res: Response) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ const ruleId = Number(req.params.id);
+
+ const existing = getDb()
+ .$client.prepare("SELECT id FROM alert_rules WHERE id = ? AND user_id = ?")
+ .get(ruleId, userId);
+ if (!existing) return res.status(404).json({ error: "Alert rule not found" });
+
+ const {
+ name,
+ hostId,
+ enabled,
+ triggerType,
+ thresholdValue,
+ thresholdDurationSeconds,
+ cooldownMinutes,
+ channels,
+ } = req.body as {
+ name?: string;
+ hostId?: number | null;
+ enabled?: boolean;
+ triggerType?: string;
+ thresholdValue?: number | null;
+ thresholdDurationSeconds?: number | null;
+ cooldownMinutes?: number;
+ channels?: number[];
+ };
+
+ if (triggerType && !VALID_TRIGGER_TYPES.has(triggerType)) {
+ return res.status(400).json({ error: "Invalid triggerType" });
+ }
+
+ try {
+ const updates: string[] = ["updated_at = ?"];
+ const params: unknown[] = [new Date().toISOString()];
+ if (name !== undefined) {
+ updates.push("name = ?");
+ params.push(name.trim());
+ }
+ if (hostId !== undefined) {
+ updates.push("host_id = ?");
+ params.push(hostId ?? null);
+ }
+ if (enabled !== undefined) {
+ updates.push("enabled = ?");
+ params.push(enabled ? 1 : 0);
+ }
+ if (triggerType !== undefined) {
+ updates.push("trigger_type = ?");
+ params.push(triggerType);
+ }
+ if (thresholdValue !== undefined) {
+ updates.push("threshold_value = ?");
+ params.push(thresholdValue ?? null);
+ }
+ if (thresholdDurationSeconds !== undefined) {
+ updates.push("threshold_duration_seconds = ?");
+ params.push(thresholdDurationSeconds ?? null);
+ }
+ if (cooldownMinutes !== undefined) {
+ updates.push("cooldown_minutes = ?");
+ params.push(cooldownMinutes);
+ }
+ params.push(ruleId, userId);
+
+ getDb()
+ .$client.prepare(
+ `UPDATE alert_rules SET ${updates.join(", ")} WHERE id = ? AND user_id = ?`,
+ )
+ .run(...params);
+
+ if (channels !== undefined) {
+ getDb()
+ .$client.prepare("DELETE FROM alert_rule_channels WHERE rule_id = ?")
+ .run(ruleId);
+ for (const channelId of channels) {
+ const owned = getDb()
+ .$client.prepare(
+ "SELECT id FROM notification_channels WHERE id = ? AND user_id = ?",
+ )
+ .get(channelId, userId);
+ if (!owned) continue;
+ getDb()
+ .$client.prepare(
+ "INSERT OR IGNORE INTO alert_rule_channels (rule_id, channel_id) VALUES (?, ?)",
+ )
+ .run(ruleId, channelId);
+ }
+ }
+
+ const row = getDb()
+ .$client.prepare("SELECT * FROM alert_rules WHERE id = ?")
+ .get(ruleId) as Record;
+ const linkedChannels = (
+ getDb()
+ .$client.prepare(
+ "SELECT channel_id FROM alert_rule_channels WHERE rule_id = ?",
+ )
+ .all(ruleId) as Array<{ channel_id: number }>
+ ).map((c) => c.channel_id);
+
+ DatabaseSaveTrigger.triggerSave("alert_rule_updated");
+ res.json({ ...row, channels: linkedChannels });
+ } catch (err) {
+ databaseLogger.error("Failed to update alert rule", {
+ operation: "update_alert_rule",
+ error: err,
+ });
+ res.status(500).json({ error: "Failed to update alert rule" });
+ }
+});
+
+/**
+ * @openapi
+ * /alert-rules/{id}:
+ * delete:
+ * summary: Delete an alert rule
+ * tags:
+ * - Alerts
+ */
+router.delete("/alert-rules/:id", (req: Request, res: Response) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ const ruleId = Number(req.params.id);
+ const existing = getDb()
+ .$client.prepare("SELECT id FROM alert_rules WHERE id = ? AND user_id = ?")
+ .get(ruleId, userId);
+ if (!existing) return res.status(404).json({ error: "Alert rule not found" });
+ getDb().$client.prepare("DELETE FROM alert_rules WHERE id = ?").run(ruleId);
+ DatabaseSaveTrigger.triggerSave("alert_rule_deleted");
+ res.json({ success: true });
+});
+
+// ---- Alert Firings ----
+
+/**
+ * @openapi
+ * /alert-firings:
+ * get:
+ * summary: List alert firings for the current user
+ * tags:
+ * - Alerts
+ */
+router.get("/alert-firings", (req, res) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ const limit = Math.min(Number(req.query.limit) || 50, 200);
+ const offset = Number(req.query.offset) || 0;
+ const acknowledgedParam = req.query.acknowledged;
+
+ try {
+ let whereClause = "af.user_id = ?";
+ const params: unknown[] = [userId];
+
+ if (acknowledgedParam === "true") {
+ whereClause += " AND af.acknowledged = 1";
+ } else if (acknowledgedParam === "false") {
+ whereClause += " AND af.acknowledged = 0";
+ }
+
+ const rows = getDb()
+ .$client.prepare(
+ `SELECT af.*, ar.name as rule_name
+ FROM alert_firings af
+ LEFT JOIN alert_rules ar ON ar.id = af.rule_id
+ WHERE ${whereClause}
+ ORDER BY af.fired_at DESC
+ LIMIT ? OFFSET ?`,
+ )
+ .all(...params, limit, offset);
+
+ const total = (
+ getDb()
+ .$client.prepare(
+ `SELECT COUNT(*) as c FROM alert_firings af WHERE ${whereClause}`,
+ )
+ .get(...params) as { c: number }
+ ).c;
+
+ res.json({ firings: rows, total });
+ } catch (err) {
+ databaseLogger.error("Failed to list alert firings", {
+ operation: "list_alert_firings",
+ error: err,
+ });
+ res.status(500).json({ error: "Failed to list alert firings" });
+ }
+});
+
+/**
+ * @openapi
+ * /alert-firings/{id}/acknowledge:
+ * post:
+ * summary: Acknowledge an alert firing
+ * tags:
+ * - Alerts
+ */
+router.post("/alert-firings/:id/acknowledge", (req: Request, res: Response) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ const firingId = Number(req.params.id);
+ getDb()
+ .$client.prepare(
+ "UPDATE alert_firings SET acknowledged = 1 WHERE id = ? AND user_id = ?",
+ )
+ .run(firingId, userId);
+ DatabaseSaveTrigger.triggerSave("alert_firing_acknowledged");
+ res.json({ success: true });
+});
+
+/**
+ * @openapi
+ * /alert-firings/acknowledge-all:
+ * post:
+ * summary: Acknowledge all alert firings for the current user
+ * tags:
+ * - Alerts
+ */
+router.post("/alert-firings/acknowledge-all", (req, res) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ getDb()
+ .$client.prepare(
+ "UPDATE alert_firings SET acknowledged = 1 WHERE user_id = ?",
+ )
+ .run(userId);
+ DatabaseSaveTrigger.triggerSave("alert_firings_acknowledged_all");
+ res.json({ success: true });
+});
+
+export default router;
diff --git a/src/backend/database/routes/credential-deploy-routes.ts b/src/backend/database/routes/credential-deploy-routes.ts
index 40db47e8..6d04da0b 100644
--- a/src/backend/database/routes/credential-deploy-routes.ts
+++ b/src/backend/database/routes/credential-deploy-routes.ts
@@ -7,6 +7,8 @@ import { eq } from "drizzle-orm";
import ssh2Pkg from "ssh2";
import { db } from "../db/index.js";
import { hosts, sshCredentials } from "../db/schema.js";
+import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
+import { applyAgentAuth } from "../../ssh/terminal-auth-helpers.js";
const { Client } = ssh2Pkg;
@@ -263,97 +265,100 @@ async function deploySSHKeyToHost(
resolve({ success: false, error: errorMessage });
});
- try {
- const connectionConfig: Record = {
- host: hostConfig.ip,
- port: hostConfig.port || 22,
- username: hostConfig.username,
- readyTimeout: 60000,
- keepaliveInterval: 30000,
- keepaliveCountMax: 3,
- tcpKeepAlive: true,
- tcpKeepAliveInitialDelay: 30000,
- algorithms: {
- kex: [
- "diffie-hellman-group14-sha256",
- "diffie-hellman-group14-sha1",
- "diffie-hellman-group1-sha1",
- "diffie-hellman-group-exchange-sha256",
- "diffie-hellman-group-exchange-sha1",
- "ecdh-sha2-nistp256",
- "ecdh-sha2-nistp384",
- "ecdh-sha2-nistp521",
- ],
- cipher: [
- "aes128-ctr",
- "aes192-ctr",
- "aes256-ctr",
- "aes128-gcm@openssh.com",
- "aes256-gcm@openssh.com",
- "aes128-cbc",
- "aes192-cbc",
- "aes256-cbc",
- "3des-cbc",
- ],
- hmac: [
- "hmac-sha2-256-etm@openssh.com",
- "hmac-sha2-512-etm@openssh.com",
- "hmac-sha2-256",
- "hmac-sha2-512",
- "hmac-sha1",
- "hmac-md5",
- ],
- compress: ["none", "zlib@openssh.com", "zlib"],
- },
- };
+ void (async () => {
+ try {
+ const connectionConfig: Record = {
+ host: hostConfig.ip,
+ port: hostConfig.port || 22,
+ username: hostConfig.username,
+ readyTimeout: 60000,
+ keepaliveInterval: 30000,
+ keepaliveCountMax: 3,
+ tcpKeepAlive: true,
+ tcpKeepAliveInitialDelay: 30000,
+ algorithms: {
+ kex: [
+ "diffie-hellman-group14-sha256",
+ "diffie-hellman-group14-sha1",
+ "diffie-hellman-group1-sha1",
+ "diffie-hellman-group-exchange-sha256",
+ "diffie-hellman-group-exchange-sha1",
+ "ecdh-sha2-nistp256",
+ "ecdh-sha2-nistp384",
+ "ecdh-sha2-nistp521",
+ ],
+ cipher: [
+ "aes128-ctr",
+ "aes192-ctr",
+ "aes256-ctr",
+ "aes128-gcm@openssh.com",
+ "aes256-gcm@openssh.com",
+ "aes128-cbc",
+ "aes192-cbc",
+ "aes256-cbc",
+ "3des-cbc",
+ ],
+ hmac: [
+ "hmac-sha2-256-etm@openssh.com",
+ "hmac-sha2-512-etm@openssh.com",
+ "hmac-sha2-256",
+ "hmac-sha2-512",
+ "hmac-sha1",
+ "hmac-md5",
+ ],
+ compress: ["none", "zlib@openssh.com", "zlib"],
+ },
+ };
- if (hostConfig.authType === "password" && hostConfig.password) {
- connectionConfig.password = hostConfig.password;
- } else if (hostConfig.authType === "key" && hostConfig.privateKey) {
- try {
- const privateKey = hostConfig.privateKey as string;
- if (
- !privateKey.includes("-----BEGIN") ||
- !privateKey.includes("-----END")
- ) {
- throw new Error("Invalid private key format");
+ if (hostConfig.authType === "password" && hostConfig.password) {
+ connectionConfig.password = hostConfig.password;
+ } else if (hostConfig.authType === "key" && hostConfig.privateKey) {
+ try {
+ const privateKey = hostConfig.privateKey as string;
+ connectionConfig.privateKey = preparePrivateKeyForSSH2(
+ privateKey,
+ hostConfig.keyPassword as string | undefined,
+ );
+
+ if (hostConfig.keyPassword) {
+ connectionConfig.passphrase = hostConfig.keyPassword;
+ }
+ } catch (keyError) {
+ clearTimeout(connectionTimeout);
+ resolve({
+ success: false,
+ error: `Invalid SSH key format: ${keyError instanceof Error ? keyError.message : "Unknown error"}`,
+ });
+ return;
}
-
- const cleanKey = privateKey
- .trim()
- .replace(/\r\n/g, "\n")
- .replace(/\r/g, "\n");
-
- connectionConfig.privateKey = Buffer.from(cleanKey, "utf8");
-
- if (hostConfig.keyPassword) {
- connectionConfig.passphrase = hostConfig.keyPassword;
+ } else if (hostConfig.authType === "agent") {
+ const result = await applyAgentAuth(
+ connectionConfig,
+ hostConfig.terminalConfig as Record | undefined,
+ );
+ if ("error" in result) {
+ clearTimeout(connectionTimeout);
+ resolve({ success: false, error: result.error });
+ return;
}
- } catch (keyError) {
+ } else {
clearTimeout(connectionTimeout);
resolve({
success: false,
- error: `Invalid SSH key format: ${keyError instanceof Error ? keyError.message : "Unknown error"}`,
+ error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`,
});
return;
}
- } else {
+
+ conn.connect(connectionConfig);
+ } catch (error) {
clearTimeout(connectionTimeout);
resolve({
success: false,
- error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`,
+ error: error instanceof Error ? error.message : "Connection failed",
});
- return;
}
-
- conn.connect(connectionConfig);
- } catch (error) {
- clearTimeout(connectionTimeout);
- resolve({
- success: false,
- error: error instanceof Error ? error.message : "Connection failed",
- });
- }
+ })();
});
}
@@ -479,6 +484,7 @@ export function registerCredentialDeployRoutes(
password: hostData.password,
privateKey: hostData.key,
keyPassword: hostData.keyPassword,
+ terminalConfig: hostData.terminalConfig,
};
if (hostData.authType === "credential" && hostData.credentialId) {
diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts
index d0b55478..e8d767a3 100644
--- a/src/backend/database/routes/credentials.ts
+++ b/src/backend/database/routes/credentials.ts
@@ -137,8 +137,7 @@ router.post(
.status(400)
.json({ error: "SSH key is required for key authentication" });
}
- const plainPassword =
- authType === "password" && password ? password : null;
+ const plainPassword = password ? password : null;
const plainKey = authType === "key" && key ? key : null;
const plainKeyPassword =
authType === "key" && keyPassword ? keyPassword : null;
@@ -156,7 +155,7 @@ router.post(
return res.status(400).json({
error: keyInfo.error
? `Invalid SSH key: ${keyInfo.error}`
- : "Unrecognized SSH key format. Only OpenSSH and PEM formats are supported (not PuTTY .ppk).",
+ : "Unrecognized SSH key format. Use an OpenSSH, PEM, or PuTTY PPK v2 RSA/DSA private key.",
});
}
}
@@ -514,10 +513,11 @@ router.put(
if (updateData.password !== undefined) {
updateFields.password = updateData.password || null;
}
+ const nextAuthType = updateData.authType ?? existing[0].authType;
if (updateData.key !== undefined) {
updateFields.key = updateData.key || null;
- if (updateData.key && existing[0].authType === "key") {
+ if (updateData.key && nextAuthType === "key") {
const keyInfo = parseSSHKey(updateData.key, updateData.keyPassword);
if (!keyInfo.success) {
authLogger.warn("SSH key parsing failed during update", {
@@ -529,7 +529,7 @@ router.put(
return res.status(400).json({
error: keyInfo.error
? `Invalid SSH key: ${keyInfo.error}`
- : "Unrecognized SSH key format. Only OpenSSH and PEM formats are supported (not PuTTY .ppk).",
+ : "Unrecognized SSH key format. Use an OpenSSH, PEM, or PuTTY PPK v2 RSA/DSA private key.",
});
}
updateFields.privateKey = keyInfo.privateKey;
diff --git a/src/backend/database/routes/dashboard-service-links-routes.ts b/src/backend/database/routes/dashboard-service-links-routes.ts
index e7dacebe..a8909a3d 100644
--- a/src/backend/database/routes/dashboard-service-links-routes.ts
+++ b/src/backend/database/routes/dashboard-service-links-routes.ts
@@ -5,19 +5,14 @@ import { dashboardLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { dashboardServiceLinks } from "../db/schema.js";
import { isNonEmptyString } from "./host-normalizers.js";
+import {
+ isValidServiceLinkUrl,
+ normalizeServiceLinkUrl,
+} from "./service-link-url.js";
import express from "express";
export const dashboardServiceLinksRouter = express.Router();
-function isValidUrl(url: string): boolean {
- try {
- const parsed = new URL(url);
- return parsed.protocol === "http:" || parsed.protocol === "https:";
- } catch {
- return false;
- }
-}
-
/**
* @openapi
* /service-links:
@@ -84,7 +79,8 @@ dashboardServiceLinksRouter.post("/", async (req: Request, res: Response) => {
if (!isNonEmptyString(label) || !isNonEmptyString(url)) {
return res.status(400).json({ error: "label and url are required" });
}
- if (!isValidUrl(url)) {
+ const normalizedUrl = normalizeServiceLinkUrl(url);
+ if (!isValidServiceLinkUrl(normalizedUrl)) {
return res
.status(400)
.json({ error: "url must be a valid http or https URL" });
@@ -105,7 +101,7 @@ dashboardServiceLinksRouter.post("/", async (req: Request, res: Response) => {
.values({
userId,
label: label.trim(),
- url: url.trim(),
+ url: normalizedUrl,
order: nextOrder,
createdAt: new Date().toISOString(),
})
@@ -233,7 +229,10 @@ dashboardServiceLinksRouter.put("/:id", async (req: Request, res: Response) => {
if (isNaN(id)) {
return res.status(400).json({ error: "Invalid id" });
}
- if (url !== undefined && !isValidUrl(url)) {
+ const normalizedUrl = isNonEmptyString(url)
+ ? normalizeServiceLinkUrl(url)
+ : undefined;
+ if (normalizedUrl !== undefined && !isValidServiceLinkUrl(normalizedUrl)) {
return res
.status(400)
.json({ error: "url must be a valid http or https URL" });
@@ -256,7 +255,7 @@ dashboardServiceLinksRouter.put("/:id", async (req: Request, res: Response) => {
const updates: Partial<{ label: string; url: string }> = {};
if (isNonEmptyString(label)) updates.label = label.trim();
- if (isNonEmptyString(url)) updates.url = url.trim();
+ if (normalizedUrl !== undefined) updates.url = normalizedUrl;
if (Object.keys(updates).length === 0) {
return res.status(400).json({ error: "Nothing to update" });
diff --git a/src/backend/database/routes/homepage-favicon-routes.ts b/src/backend/database/routes/homepage-favicon-routes.ts
new file mode 100644
index 00000000..8f048ee7
--- /dev/null
+++ b/src/backend/database/routes/homepage-favicon-routes.ts
@@ -0,0 +1,103 @@
+import type { Request, Response } from "express";
+import { homepageLogger } from "../../utils/logger.js";
+import express from "express";
+import https from "https";
+import http from "http";
+
+export const homepageFaviconRouter = express.Router();
+
+// Simple LRU cache: url -> { data: Buffer, contentType: string, expires: number }
+const faviconCache = new Map<
+ string,
+ { data: Buffer; contentType: string; expires: number }
+>();
+const CACHE_SIZE = 100;
+const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
+
+function evictIfNeeded() {
+ if (faviconCache.size >= CACHE_SIZE) {
+ const oldest = faviconCache.keys().next().value;
+ if (oldest) faviconCache.delete(oldest);
+ }
+}
+
+function fetchUrl(url: string): Promise<{ data: Buffer; contentType: string }> {
+ return new Promise((resolve, reject) => {
+ const mod = url.startsWith("https") ? https : http;
+ const req = mod.get(url, { timeout: 5000 }, (res) => {
+ const chunks: Buffer[] = [];
+ res.on("data", (chunk: Buffer) => chunks.push(chunk));
+ res.on("end", () => {
+ resolve({
+ data: Buffer.concat(chunks),
+ contentType: res.headers["content-type"] || "image/x-icon",
+ });
+ });
+ res.on("error", reject);
+ });
+ req.on("error", reject);
+ req.on("timeout", () => {
+ req.destroy();
+ reject(new Error("Favicon fetch timeout"));
+ });
+ });
+}
+
+/**
+ * @openapi
+ * /homepage/favicon:
+ * get:
+ * summary: Proxy favicon fetch
+ * description: Fetches and caches a site favicon server-side to avoid CORS issues.
+ * tags:
+ * - Homepage
+ * parameters:
+ * - in: query
+ * name: url
+ * required: true
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: Favicon image.
+ * 400:
+ * description: Invalid URL.
+ * 500:
+ * description: Failed to fetch favicon.
+ */
+homepageFaviconRouter.get("/", async (req: Request, res: Response) => {
+ const rawUrl = req.query.url as string;
+ if (!rawUrl) return res.status(400).json({ error: "url is required" });
+
+ let domain: string;
+ try {
+ domain = new URL(rawUrl).hostname;
+ } catch {
+ return res.status(400).json({ error: "Invalid URL" });
+ }
+
+ const cached = faviconCache.get(domain);
+ if (cached && cached.expires > Date.now()) {
+ res.setHeader("Content-Type", cached.contentType);
+ res.setHeader("Cache-Control", "public, max-age=86400");
+ return res.send(cached.data);
+ }
+
+ const faviconUrl = `https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(domain)}`;
+
+ try {
+ const { data, contentType } = await fetchUrl(faviconUrl);
+ evictIfNeeded();
+ faviconCache.set(domain, {
+ data,
+ contentType,
+ expires: Date.now() + CACHE_TTL_MS,
+ });
+ res.setHeader("Content-Type", contentType);
+ res.setHeader("Cache-Control", "public, max-age=86400");
+ res.send(data);
+ } catch (err) {
+ homepageLogger.warn("Failed to fetch favicon", { domain });
+ res.status(500).json({ error: "Failed to fetch favicon" });
+ }
+});
diff --git a/src/backend/database/routes/homepage-items-routes.ts b/src/backend/database/routes/homepage-items-routes.ts
new file mode 100644
index 00000000..a38a6c1f
--- /dev/null
+++ b/src/backend/database/routes/homepage-items-routes.ts
@@ -0,0 +1,225 @@
+import type { AuthenticatedRequest } from "../../../types/index.js";
+import type { Request, Response } from "express";
+import { and, asc, eq } from "drizzle-orm";
+import { homepageLogger } from "../../utils/logger.js";
+import { db } from "../db/index.js";
+import { homepageItems } from "../db/schema.js";
+import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
+import express from "express";
+
+export const homepageItemsRouter = express.Router();
+
+/**
+ * @openapi
+ * /homepage/items:
+ * get:
+ * summary: Get homepage items
+ * description: Returns all homepage widget items for the authenticated user.
+ * tags:
+ * - Homepage
+ * responses:
+ * 200:
+ * description: List of homepage items.
+ * 500:
+ * description: Failed to fetch homepage items.
+ */
+homepageItemsRouter.get("/", async (req: Request, res: Response) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ try {
+ const items = await db
+ .select()
+ .from(homepageItems)
+ .where(eq(homepageItems.userId, userId))
+ .orderBy(asc(homepageItems.id));
+ res.json(items);
+ } catch (err) {
+ homepageLogger.error("Failed to fetch homepage items", err);
+ res.status(500).json({ error: "Failed to fetch homepage items" });
+ }
+});
+
+/**
+ * @openapi
+ * /homepage/items:
+ * post:
+ * summary: Create homepage item
+ * description: Creates a new homepage widget item for the authenticated user.
+ * tags:
+ * - Homepage
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required:
+ * - typeId
+ * properties:
+ * typeId:
+ * type: string
+ * title:
+ * type: string
+ * config:
+ * type: object
+ * responses:
+ * 201:
+ * description: Homepage item created.
+ * 400:
+ * description: Invalid data.
+ * 500:
+ * description: Failed to create homepage item.
+ */
+homepageItemsRouter.post("/", async (req: Request, res: Response) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ const { typeId, title, config } = req.body;
+
+ if (!typeId || typeof typeId !== "string") {
+ return res.status(400).json({ error: "typeId is required" });
+ }
+
+ try {
+ const [created] = await db
+ .insert(homepageItems)
+ .values({
+ userId,
+ typeId,
+ title: title ?? null,
+ config: config ? JSON.stringify(config) : "{}",
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ })
+ .returning();
+ DatabaseSaveTrigger.triggerSave("homepage_item_created");
+ res.status(201).json(created);
+ } catch (err) {
+ homepageLogger.error("Failed to create homepage item", err);
+ res.status(500).json({ error: "Failed to create homepage item" });
+ }
+});
+
+/**
+ * @openapi
+ * /homepage/items/{id}:
+ * put:
+ * summary: Update homepage item
+ * description: Updates a homepage widget item.
+ * tags:
+ * - Homepage
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema:
+ * type: integer
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * title:
+ * type: string
+ * config:
+ * type: object
+ * responses:
+ * 200:
+ * description: Homepage item updated.
+ * 400:
+ * description: Invalid data.
+ * 404:
+ * description: Not found.
+ * 500:
+ * description: Failed to update homepage item.
+ */
+homepageItemsRouter.put("/:id", async (req: Request, res: Response) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ const id = parseInt(req.params.id as string);
+ if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
+
+ const { title, config } = req.body;
+
+ try {
+ const existing = await db
+ .select()
+ .from(homepageItems)
+ .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)));
+
+ if (existing.length === 0) {
+ return res.status(404).json({ error: "Not found" });
+ }
+
+ const updates: Partial<{
+ title: string | null;
+ config: string;
+ updatedAt: string;
+ }> = {
+ updatedAt: new Date().toISOString(),
+ };
+ if (title !== undefined) updates.title = title;
+ if (config !== undefined) updates.config = JSON.stringify(config);
+
+ const [updated] = await db
+ .update(homepageItems)
+ .set(updates)
+ .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
+ .returning();
+
+ DatabaseSaveTrigger.triggerSave("homepage_item_updated");
+ res.json(updated);
+ } catch (err) {
+ homepageLogger.error("Failed to update homepage item", err);
+ res.status(500).json({ error: "Failed to update homepage item" });
+ }
+});
+
+/**
+ * @openapi
+ * /homepage/items/{id}:
+ * delete:
+ * summary: Delete homepage item
+ * description: Deletes a homepage widget item and cascades deletion to children if a folder.
+ * tags:
+ * - Homepage
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema:
+ * type: integer
+ * responses:
+ * 200:
+ * description: Homepage item deleted.
+ * 400:
+ * description: Invalid id.
+ * 404:
+ * description: Not found.
+ * 500:
+ * description: Failed to delete homepage item.
+ */
+homepageItemsRouter.delete("/:id", async (req: Request, res: Response) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ const id = parseInt(req.params.id as string);
+ if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
+
+ try {
+ const existing = await db
+ .select()
+ .from(homepageItems)
+ .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)));
+
+ if (existing.length === 0) {
+ return res.status(404).json({ error: "Not found" });
+ }
+
+ await db
+ .delete(homepageItems)
+ .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)));
+
+ DatabaseSaveTrigger.triggerSave("homepage_item_deleted");
+ res.json({ message: "Homepage item deleted" });
+ } catch (err) {
+ homepageLogger.error("Failed to delete homepage item", err);
+ res.status(500).json({ error: "Failed to delete homepage item" });
+ }
+});
diff --git a/src/backend/database/routes/homepage-layout-routes.ts b/src/backend/database/routes/homepage-layout-routes.ts
new file mode 100644
index 00000000..322917cb
--- /dev/null
+++ b/src/backend/database/routes/homepage-layout-routes.ts
@@ -0,0 +1,110 @@
+import type { AuthenticatedRequest } from "../../../types/index.js";
+import type { Request, Response } from "express";
+import { eq } from "drizzle-orm";
+import { homepageLogger } from "../../utils/logger.js";
+import { db } from "../db/index.js";
+import { homepageLayouts } from "../db/schema.js";
+import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
+import express from "express";
+
+export const homepageLayoutRouter = express.Router();
+
+/**
+ * @openapi
+ * /homepage/layout:
+ * get:
+ * summary: Get homepage layout
+ * description: Returns the homepage canvas layout (widget positions, pan, zoom) for the authenticated user.
+ * tags:
+ * - Homepage
+ * responses:
+ * 200:
+ * description: Layout data or null.
+ * 500:
+ * description: Failed to fetch homepage layout.
+ */
+homepageLayoutRouter.get("/", async (req: Request, res: Response) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ try {
+ const rows = await db
+ .select()
+ .from(homepageLayouts)
+ .where(eq(homepageLayouts.userId, userId));
+
+ if (rows.length === 0) {
+ return res.json(null);
+ }
+
+ const row = rows[0];
+ const parsed = JSON.parse(row.layout || "{}");
+ res.json({ ...row, layout: parsed });
+ } catch (err) {
+ homepageLogger.error("Failed to fetch homepage layout", err);
+ res.status(500).json({ error: "Failed to fetch homepage layout" });
+ }
+});
+
+/**
+ * @openapi
+ * /homepage/layout:
+ * put:
+ * summary: Save homepage layout
+ * description: Saves or updates the homepage canvas layout for the authenticated user.
+ * tags:
+ * - Homepage
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * entries:
+ * type: array
+ * pan:
+ * type: object
+ * zoom:
+ * type: number
+ * responses:
+ * 200:
+ * description: Layout saved.
+ * 500:
+ * description: Failed to save homepage layout.
+ */
+homepageLayoutRouter.put("/", async (req: Request, res: Response) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ const layoutData = req.body;
+
+ try {
+ const existing = await db
+ .select({ id: homepageLayouts.id })
+ .from(homepageLayouts)
+ .where(eq(homepageLayouts.userId, userId));
+
+ const layoutJson = JSON.stringify(layoutData);
+ const now = new Date().toISOString();
+
+ if (existing.length === 0) {
+ const [created] = await db
+ .insert(homepageLayouts)
+ .values({ userId, layout: layoutJson, updatedAt: now })
+ .returning();
+ const parsed = JSON.parse(created.layout);
+ DatabaseSaveTrigger.triggerSave("homepage_layout_saved");
+ return res.json({ ...created, layout: parsed });
+ }
+
+ const [updated] = await db
+ .update(homepageLayouts)
+ .set({ layout: layoutJson, updatedAt: now })
+ .where(eq(homepageLayouts.userId, userId))
+ .returning();
+
+ const parsed = JSON.parse(updated.layout);
+ DatabaseSaveTrigger.triggerSave("homepage_layout_saved");
+ res.json({ ...updated, layout: parsed });
+ } catch (err) {
+ homepageLogger.error("Failed to save homepage layout", err);
+ res.status(500).json({ error: "Failed to save homepage layout" });
+ }
+});
diff --git a/src/backend/database/routes/homepage-ping-routes.ts b/src/backend/database/routes/homepage-ping-routes.ts
new file mode 100644
index 00000000..7b2995c2
--- /dev/null
+++ b/src/backend/database/routes/homepage-ping-routes.ts
@@ -0,0 +1,127 @@
+import type { Request, Response } from "express";
+import express from "express";
+import https from "https";
+import http from "http";
+import { homepageLogger } from "../../utils/logger.js";
+
+export const homepagePingRouter = express.Router();
+
+interface PingCacheEntry {
+ ok: boolean;
+ statusCode: number | null;
+ latencyMs: number;
+ expires: number;
+}
+
+const pingCache = new Map();
+const CACHE_SIZE = 200;
+const FETCH_TIMEOUT_MS = 5000;
+
+function pingUrl(
+ url: string,
+): Promise<{ ok: boolean; statusCode: number | null; latencyMs: number }> {
+ return new Promise((resolve) => {
+ const start = performance.now();
+ const mod = url.startsWith("https") ? https : http;
+
+ const done = (ok: boolean, statusCode: number | null) => {
+ resolve({
+ ok,
+ statusCode,
+ latencyMs: Math.round(performance.now() - start),
+ });
+ };
+
+ const tryGet = () => {
+ const req = mod.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => {
+ res.resume();
+ const code = res.statusCode ?? null;
+ done(code !== null && code < 400, code);
+ });
+ req.on("error", () => done(false, null));
+ req.on("timeout", () => {
+ req.destroy();
+ done(false, null);
+ });
+ };
+
+ const req = mod.request(
+ url,
+ { method: "HEAD", timeout: FETCH_TIMEOUT_MS },
+ (res) => {
+ res.resume();
+ const code = res.statusCode ?? null;
+ if (code === 405) {
+ tryGet();
+ } else {
+ done(code !== null && code < 400, code);
+ }
+ },
+ );
+ req.on("error", () => done(false, null));
+ req.on("timeout", () => {
+ req.destroy();
+ done(false, null);
+ });
+ req.end();
+ });
+}
+
+/**
+ * @openapi
+ * /homepage/ping:
+ * get:
+ * summary: Check the HTTP reachability and latency of a URL
+ * tags:
+ * - Homepage
+ * parameters:
+ * - in: query
+ * name: url
+ * required: true
+ * schema:
+ * type: string
+ * - in: query
+ * name: ttl
+ * schema:
+ * type: integer
+ * description: Cache TTL in seconds (min 10)
+ * responses:
+ * 200:
+ * description: Ping result with ok, statusCode and latencyMs.
+ * 400:
+ * description: Invalid or missing URL.
+ */
+homepagePingRouter.get("/", async (req: Request, res: Response) => {
+ let targetUrl = req.query.url as string;
+ const ttl = Math.max(10, Number(req.query.ttl) || 30) * 1000;
+
+ if (!targetUrl) return res.status(400).json({ error: "url is required" });
+ if (!/^https?:\/\//i.test(targetUrl)) targetUrl = `https://${targetUrl}`;
+ try {
+ new URL(targetUrl);
+ } catch {
+ return res.status(400).json({ error: "Invalid URL" });
+ }
+
+ const cached = pingCache.get(targetUrl);
+ if (cached && cached.expires > Date.now()) {
+ return res.json({
+ ok: cached.ok,
+ statusCode: cached.statusCode,
+ latencyMs: cached.latencyMs,
+ });
+ }
+
+ try {
+ const result = await pingUrl(targetUrl);
+ if (pingCache.size >= CACHE_SIZE) {
+ const oldest = pingCache.keys().next().value;
+ if (oldest) pingCache.delete(oldest);
+ }
+ pingCache.set(targetUrl, { ...result, expires: Date.now() + ttl });
+ res.json(result);
+ } catch (err) {
+ homepageLogger.warn("Ping failed", { targetUrl });
+ res.status(500).json({ error: "Ping failed" });
+ }
+});
diff --git a/src/backend/database/routes/homepage-proxy-routes.ts b/src/backend/database/routes/homepage-proxy-routes.ts
new file mode 100644
index 00000000..72115317
--- /dev/null
+++ b/src/backend/database/routes/homepage-proxy-routes.ts
@@ -0,0 +1,100 @@
+import type { Request, Response } from "express";
+import express from "express";
+import https from "https";
+import http from "http";
+import { homepageLogger } from "../../utils/logger.js";
+
+export const homepageProxyRouter = express.Router();
+
+interface ProxyCacheEntry {
+ data: unknown;
+ expires: number;
+}
+
+const proxyCache = new Map();
+const CACHE_SIZE = 50;
+const FETCH_TIMEOUT_MS = 8000;
+
+function fetchJson(url: string): Promise {
+ return new Promise((resolve, reject) => {
+ const mod = url.startsWith("https") ? https : http;
+ const req = mod.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => {
+ const chunks: Buffer[] = [];
+ res.on("data", (chunk: Buffer) => chunks.push(chunk));
+ res.on("end", () => {
+ try {
+ const text = Buffer.concat(chunks).toString("utf-8");
+ resolve(JSON.parse(text));
+ } catch {
+ reject(new Error("Response is not valid JSON"));
+ }
+ });
+ res.on("error", reject);
+ });
+ req.on("error", reject);
+ req.on("timeout", () => {
+ req.destroy();
+ reject(new Error("Fetch timeout"));
+ });
+ });
+}
+
+/**
+ * @openapi
+ * /homepage/proxy:
+ * get:
+ * summary: Proxy a JSON API URL and return the parsed response
+ * tags:
+ * - Homepage
+ * parameters:
+ * - in: query
+ * name: url
+ * required: true
+ * schema:
+ * type: string
+ * - in: query
+ * name: ttl
+ * schema:
+ * type: integer
+ * description: Cache TTL in seconds (min 10, default 60)
+ * responses:
+ * 200:
+ * description: The JSON body returned by the target URL.
+ * 400:
+ * description: Invalid or missing URL, or non-JSON response.
+ * 500:
+ * description: Failed to fetch the target URL.
+ */
+homepageProxyRouter.get("/", async (req: Request, res: Response) => {
+ const targetUrl = req.query.url as string;
+ const ttl = Math.max(10, Number(req.query.ttl) || 60) * 1000;
+
+ if (!targetUrl) return res.status(400).json({ error: "url is required" });
+ try {
+ new URL(targetUrl);
+ } catch {
+ return res.status(400).json({ error: "Invalid URL" });
+ }
+
+ const cached = proxyCache.get(targetUrl);
+ if (cached && cached.expires > Date.now()) {
+ return res.json(cached.data);
+ }
+
+ try {
+ const data = await fetchJson(targetUrl);
+ if (proxyCache.size >= CACHE_SIZE) {
+ const oldest = proxyCache.keys().next().value;
+ if (oldest) proxyCache.delete(oldest);
+ }
+ proxyCache.set(targetUrl, { data, expires: Date.now() + ttl });
+ res.json(data);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : "Unknown error";
+ homepageLogger.warn("Proxy fetch failed", { targetUrl, msg });
+ if (msg.includes("not valid JSON")) {
+ return res.status(400).json({ error: "Response is not valid JSON" });
+ }
+ res.status(500).json({ error: "Failed to fetch URL" });
+ }
+});
diff --git a/src/backend/database/routes/homepage-rss-routes.ts b/src/backend/database/routes/homepage-rss-routes.ts
new file mode 100644
index 00000000..7312243c
--- /dev/null
+++ b/src/backend/database/routes/homepage-rss-routes.ts
@@ -0,0 +1,148 @@
+import type { Request, Response } from "express";
+import express from "express";
+import https from "https";
+import http from "http";
+import { homepageLogger } from "../../utils/logger.js";
+
+export const homepageRssRouter = express.Router();
+
+const rssCache = new Map();
+const CACHE_TTL_MS = 1000 * 60 * 15; // 15 minutes
+const CACHE_SIZE = 50;
+const FETCH_TIMEOUT_MS = 8000;
+
+interface RssItem {
+ title: string;
+ link: string;
+ pubDate: string | null;
+ description: string | null;
+}
+
+function fetchXml(url: string): Promise {
+ return new Promise((resolve, reject) => {
+ const mod = url.startsWith("https") ? https : http;
+ const req = mod.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => {
+ const chunks: Buffer[] = [];
+ res.on("data", (chunk: Buffer) => chunks.push(chunk));
+ res.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
+ res.on("error", reject);
+ });
+ req.on("error", reject);
+ req.on("timeout", () => {
+ req.destroy();
+ reject(new Error("RSS fetch timeout"));
+ });
+ });
+}
+
+function parseRss(xml: string): RssItem[] {
+ const items: RssItem[] = [];
+ const itemRegex = /]([\s\S]*?)<\/item>/gi;
+ let match: RegExpExecArray | null;
+
+ const getText = (tag: string, src: string): string | null => {
+ const m = new RegExp(
+ `<${tag}[^>]*><\\/${tag}>|<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`,
+ "i",
+ ).exec(src);
+ if (!m) return null;
+ return (m[1] ?? m[2]).trim();
+ };
+
+ const getLink = (src: string): string => {
+ // Self-closing (BBC style)
+ const selfClose = /]+href="([^"]+)"[^>]*\/?>/i.exec(src);
+ if (selfClose) return selfClose[1];
+ // Plain text url
+ return getText("link", src) ?? "";
+ };
+
+ while ((match = itemRegex.exec(xml)) !== null) {
+ const src = match[1];
+ items.push({
+ title: getText("title", src) ?? "(no title)",
+ link: getLink(src),
+ pubDate: getText("pubDate", src) ?? getText("updated", src),
+ description: getText("description", src) ?? getText("summary", src),
+ });
+ if (items.length >= 50) break;
+ }
+
+ // Atom feed fallback
+ if (items.length === 0) {
+ const entryRegex = /]([\s\S]*?)<\/entry>/gi;
+ while ((match = entryRegex.exec(xml)) !== null) {
+ const src = match[1];
+ const linkMatch = /]+href="([^"]+)"/.exec(src);
+ items.push({
+ title: getText("title", src) ?? "(no title)",
+ link: linkMatch?.[1] ?? "",
+ pubDate: getText("published", src) ?? getText("updated", src),
+ description: getText("summary", src) ?? getText("content", src),
+ });
+ if (items.length >= 50) break;
+ }
+ }
+
+ return items;
+}
+
+/**
+ * @openapi
+ * /homepage/rss:
+ * get:
+ * summary: Proxy and parse an RSS/Atom feed
+ * tags:
+ * - Homepage
+ * parameters:
+ * - in: query
+ * name: url
+ * required: true
+ * schema:
+ * type: string
+ * - in: query
+ * name: max
+ * schema:
+ * type: integer
+ * default: 10
+ * responses:
+ * 200:
+ * description: Array of feed items.
+ * 400:
+ * description: Invalid or missing URL.
+ * 500:
+ * description: Failed to fetch or parse the feed.
+ */
+homepageRssRouter.get("/", async (req: Request, res: Response) => {
+ const feedUrl = req.query.url as string;
+ const max = Math.min(50, Math.max(1, Number(req.query.max) || 10));
+
+ if (!feedUrl) return res.status(400).json({ error: "url is required" });
+
+ try {
+ new URL(feedUrl);
+ } catch {
+ return res.status(400).json({ error: "Invalid URL" });
+ }
+
+ const cached = rssCache.get(feedUrl);
+ if (cached && cached.expires > Date.now()) {
+ return res.json(cached.data.slice(0, max));
+ }
+
+ try {
+ const xml = await fetchXml(feedUrl);
+ const items = parseRss(xml);
+
+ if (rssCache.size >= CACHE_SIZE) {
+ const oldest = rssCache.keys().next().value;
+ if (oldest) rssCache.delete(oldest);
+ }
+ rssCache.set(feedUrl, { data: items, expires: Date.now() + CACHE_TTL_MS });
+
+ res.json(items.slice(0, max));
+ } catch (err) {
+ homepageLogger.warn("Failed to fetch RSS feed", { feedUrl });
+ res.status(500).json({ error: "Failed to fetch feed" });
+ }
+});
diff --git a/src/backend/database/routes/host-bulk-routes.ts b/src/backend/database/routes/host-bulk-routes.ts
index 54cbf1fd..b7acef60 100644
--- a/src/backend/database/routes/host-bulk-routes.ts
+++ b/src/backend/database/routes/host-bulk-routes.ts
@@ -20,6 +20,35 @@ type SSHConfigHost = {
proxyJump?: string;
};
+type ShareCredential = {
+ alias?: unknown;
+ name?: unknown;
+ description?: unknown;
+ folder?: unknown;
+ tags?: unknown;
+ authType?: unknown;
+ username?: unknown;
+ keyType?: unknown;
+};
+
+function textValue(value: unknown): string | null {
+ return typeof value === "string" && value.trim() ? value.trim() : null;
+}
+
+function tagString(value: unknown): string {
+ if (Array.isArray(value)) {
+ return value
+ .map((tag) => textValue(tag))
+ .filter((tag): tag is string => !!tag)
+ .join(",");
+ }
+ return textValue(value) || "";
+}
+
+function normalizeCredentialAuthType(value: unknown): "password" | "key" {
+ return value === "key" ? "key" : "password";
+}
+
export function parseSSHConfig(content: string): SSHConfigHost[] {
const results: SSHConfigHost[] = [];
let current: SSHConfigHost | null = null;
@@ -280,7 +309,11 @@ export function registerHostBulkRoutes(
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
- const { hosts: hostsToImport, overwrite } = req.body;
+ const {
+ hosts: hostsToImport,
+ overwrite,
+ credentials: credentialsToImport,
+ } = req.body;
if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) {
return res
@@ -302,6 +335,80 @@ export function registerHostBulkRoutes(
errors: [] as string[],
};
+ const credentialAliasMap = new Map();
+ const addCredentialAlias = (alias: unknown, id: number) => {
+ const key = textValue(alias);
+ if (key) credentialAliasMap.set(key.toLowerCase(), id);
+ };
+
+ try {
+ const existingCredentials = await SimpleDBOps.select<
+ Record
+ >(
+ db
+ .select()
+ .from(sshCredentials)
+ .where(eq(sshCredentials.userId, userId)),
+ "ssh_credentials",
+ userId,
+ );
+
+ for (const credential of existingCredentials) {
+ addCredentialAlias(credential.name, credential.id as number);
+ }
+
+ if (Array.isArray(credentialsToImport)) {
+ for (const rawCredential of credentialsToImport as ShareCredential[]) {
+ const alias = textValue(rawCredential.alias);
+ const name = textValue(rawCredential.name) || alias;
+ if (!alias || !name) continue;
+
+ const existingId = credentialAliasMap.get(name.toLowerCase());
+ if (existingId) {
+ addCredentialAlias(alias, existingId);
+ continue;
+ }
+
+ const now = new Date().toISOString();
+ const created = await SimpleDBOps.insert(
+ sshCredentials,
+ "ssh_credentials",
+ {
+ userId,
+ name,
+ description:
+ textValue(rawCredential.description) ||
+ "Imported placeholder. Add the secret before connecting.",
+ folder: textValue(rawCredential.folder),
+ tags: tagString(rawCredential.tags),
+ authType: normalizeCredentialAuthType(rawCredential.authType),
+ username: textValue(rawCredential.username),
+ password: null,
+ key: null,
+ privateKey: null,
+ publicKey: null,
+ keyPassword: null,
+ keyType: textValue(rawCredential.keyType),
+ detectedKeyType: null,
+ usageCount: 0,
+ lastUsed: null,
+ createdAt: now,
+ updatedAt: now,
+ },
+ userId,
+ );
+
+ const createdCredential = created as Record;
+ addCredentialAlias(alias, createdCredential.id as number);
+ addCredentialAlias(name, createdCredential.id as number);
+ }
+ }
+ } catch (error) {
+ results.errors.push(
+ `Credential placeholders: ${error instanceof Error ? error.message : "failed to prepare credential aliases"}`,
+ );
+ }
+
let existingHostMap: Map | undefined;
if (overwrite) {
try {
@@ -326,6 +433,17 @@ export function registerHostBulkRoutes(
try {
const effectiveConnectionType = hostData.connectionType || "ssh";
+ if (
+ effectiveConnectionType === "ssh" &&
+ hostData.authType === "credential" &&
+ !hostData.credentialId &&
+ hostData.credentialAlias
+ ) {
+ hostData.credentialId = credentialAliasMap.get(
+ hostData.credentialAlias.toLowerCase(),
+ );
+ }
+
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
results.failed++;
results.errors.push(
@@ -355,11 +473,12 @@ export function registerHostBulkRoutes(
"none",
"opkssh",
"tailscale",
+ "vault",
].includes(hostData.authType)
) {
results.failed++;
results.errors.push(
- `Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', or 'tailscale'`,
+ `Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', 'tailscale', or 'vault'`,
);
continue;
}
diff --git a/src/backend/database/routes/host-normalizers.test.ts b/src/backend/database/routes/host-normalizers.test.ts
index d406951e..7dae39f6 100644
--- a/src/backend/database/routes/host-normalizers.test.ts
+++ b/src/backend/database/routes/host-normalizers.test.ts
@@ -122,6 +122,16 @@ describe("normalizeImportedHost", () => {
expect(host.credentialId).toBe(7);
expect(host.authType).toBe("credential");
});
+
+ it("infers credential auth from share aliases", () => {
+ const aliasHost = normalizeImportedHost({ credentialAlias: "prod-admin" });
+ expect(aliasHost.credentialAlias).toBe("prod-admin");
+ expect(aliasHost.authType).toBe("credential");
+
+ const nameHost = normalizeImportedHost({ credentialName: "ops-key" });
+ expect(nameHost.credentialAlias).toBe("ops-key");
+ expect(nameHost.authType).toBe("credential");
+ });
});
describe("stripSensitiveFields", () => {
diff --git a/src/backend/database/routes/host-normalizers.ts b/src/backend/database/routes/host-normalizers.ts
index 06978572..9dc4e1a7 100644
--- a/src/backend/database/routes/host-normalizers.ts
+++ b/src/backend/database/routes/host-normalizers.ts
@@ -94,6 +94,7 @@ export type NormalizedImportedHost = Record & {
keyPassword?: string;
keyType?: string;
credentialId?: number;
+ credentialAlias?: string;
pin?: unknown;
enableTerminal?: unknown;
enableTunnel?: unknown;
@@ -138,6 +139,8 @@ export type NormalizedImportedHost = Record & {
export function normalizeImportedHost(
hostData: Record,
): NormalizedImportedHost {
+ const credentialAlias =
+ asString(hostData.credentialAlias) || asString(hostData.credentialName);
const connectionType =
asString(hostData.connectionType) ||
(asBoolean(hostData.enableRdp)
@@ -172,10 +175,15 @@ export function normalizeImportedHost(
folder: asString(hostData.folder) || asString(hostData.group),
tags: normalizeImportTags(hostData.tags),
credentialId: asInteger(hostData.credentialId),
+ credentialAlias,
authType:
asString(hostData.authType) ||
asString(hostData.authMethod) ||
- (hostData.credentialId ? "credential" : hostData.key ? "key" : undefined),
+ (hostData.credentialId || credentialAlias
+ ? "credential"
+ : hostData.key
+ ? "key"
+ : undefined),
enableSsh:
hostData.enableSsh === undefined
? connectionType === "ssh"
diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts
index 06b950c4..9d38b10d 100644
--- a/src/backend/database/routes/host.ts
+++ b/src/backend/database/routes/host.ts
@@ -79,6 +79,69 @@ const permissionManager = PermissionManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
+type ShareCredentialExport = {
+ alias: string;
+ name: string;
+ authType: "password" | "key";
+ username: string | null;
+ description: string | null;
+ folder: string | null;
+ tags: string[];
+ keyType: string | null;
+};
+
+function parseJsonField(value: unknown, fallback: unknown) {
+ if (!value || typeof value !== "string") return fallback;
+ try {
+ return JSON.parse(value);
+ } catch {
+ return fallback;
+ }
+}
+
+function splitTags(value: unknown): string[] {
+ if (Array.isArray(value))
+ return value.filter((tag) => typeof tag === "string");
+ if (typeof value !== "string") return [];
+ return value.split(",").filter(Boolean);
+}
+
+function uniqueAlias(base: string, used: Set): string {
+ const normalized =
+ base
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9._-]+/g, "-")
+ .replace(/^-+|-+$/g, "") || "credential";
+ let alias = normalized;
+ let suffix = 2;
+ while (used.has(alias)) {
+ alias = `${normalized}-${suffix++}`;
+ }
+ used.add(alias);
+ return alias;
+}
+
+function safeCredentialExport(
+ credential: Record,
+ alias: string,
+): ShareCredentialExport {
+ return {
+ alias,
+ name: String(credential.name || alias),
+ authType: credential.authType === "key" ? "key" : "password",
+ username:
+ typeof credential.username === "string" ? credential.username : null,
+ description:
+ typeof credential.description === "string"
+ ? credential.description
+ : null,
+ folder: typeof credential.folder === "string" ? credential.folder : null,
+ tags: splitTags(credential.tags),
+ keyType: typeof credential.keyType === "string" ? credential.keyType : null,
+ };
+}
+
registerHostInternalRoutes(router);
/**
@@ -146,6 +209,7 @@ router.post(
authType,
useWarpgate,
credentialId,
+ vaultProfileId,
key,
keyPassword,
keyType,
@@ -200,6 +264,8 @@ router.post(
rdpDomain,
rdpSecurity,
rdpIgnoreCert,
+ vncAuthType,
+ vncCredentialId,
vncPassword,
vncUser,
telnetUser,
@@ -248,6 +314,7 @@ router.post(
authType: effectiveAuthType,
useWarpgate: useWarpgate ? 1 : 0,
credentialId: credentialId || null,
+ vaultProfileId: vaultProfileId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0,
@@ -322,6 +389,11 @@ router.post(
rdpDomain: rdpDomain || null,
rdpSecurity: rdpSecurity || null,
rdpIgnoreCert: rdpIgnoreCert ? 1 : 0,
+ vncAuthType: enableVnc ? vncAuthType || null : null,
+ vncCredentialId:
+ enableVnc && vncAuthType === "credential" && vncCredentialId
+ ? vncCredentialId
+ : null,
vncUser: vncUser || null,
telnetUser: telnetUser || null,
};
@@ -339,19 +411,6 @@ router.post(
sshDataObj.keyType = null;
} else if (effectiveAuthType === "key") {
if (key && typeof key === "string") {
- if (!key.includes("-----BEGIN") || !key.includes("-----END")) {
- sshLogger.warn("Invalid SSH key format provided", {
- operation: "host_create",
- userId,
- name,
- ip,
- port,
- });
- return res.status(400).json({
- error: "Invalid SSH key format. Key must be in PEM format.",
- });
- }
-
const keyValidation = parseSSHKey(
key,
typeof keyPassword === "string" ? keyPassword : undefined,
@@ -375,6 +434,11 @@ router.post(
sshDataObj.keyPassword = keyPassword || null;
sshDataObj.keyType = keyType;
sshDataObj.password = null;
+ } else if (effectiveAuthType === "agent") {
+ sshDataObj.password = null;
+ sshDataObj.key = null;
+ sshDataObj.keyPassword = null;
+ sshDataObj.keyType = null;
} else {
sshDataObj.password = null;
sshDataObj.key = null;
@@ -721,6 +785,7 @@ router.put(
authType,
useWarpgate,
credentialId,
+ vaultProfileId,
key,
keyPassword,
keyType,
@@ -775,6 +840,8 @@ router.put(
rdpDomain,
rdpSecurity,
rdpIgnoreCert,
+ vncAuthType,
+ vncCredentialId,
vncPassword,
vncUser,
telnetUser,
@@ -820,6 +887,7 @@ router.put(
authType: effectiveAuthType,
useWarpgate: useWarpgate ? 1 : 0,
credentialId: credentialId || null,
+ vaultProfileId: vaultProfileId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0,
@@ -894,6 +962,11 @@ router.put(
rdpDomain: rdpDomain || null,
rdpSecurity: rdpSecurity || null,
rdpIgnoreCert: rdpIgnoreCert ? 1 : 0,
+ vncAuthType: enableVnc ? vncAuthType || null : null,
+ vncCredentialId:
+ enableVnc && vncAuthType === "credential" && vncCredentialId
+ ? vncCredentialId
+ : null,
vncUser: vncUser || null,
telnetUser: telnetUser || null,
};
@@ -915,20 +988,6 @@ router.put(
sshDataObj.keyType = null;
} else if (effectiveAuthType === "key") {
if (key && typeof key === "string") {
- if (!key.includes("-----BEGIN") || !key.includes("-----END")) {
- sshLogger.warn("Invalid SSH key format provided", {
- operation: "host_update",
- hostId: parseInt(hostId),
- userId,
- name,
- ip,
- port,
- });
- return res.status(400).json({
- error: "Invalid SSH key format. Key must be in PEM format.",
- });
- }
-
const keyValidation = parseSSHKey(
key,
typeof keyPassword === "string" ? keyPassword : undefined,
@@ -957,6 +1016,11 @@ router.put(
sshDataObj.keyType = keyType;
}
sshDataObj.password = null;
+ } else if (effectiveAuthType === "agent") {
+ sshDataObj.password = null;
+ sshDataObj.key = null;
+ sshDataObj.keyPassword = null;
+ sshDataObj.keyType = null;
} else {
sshDataObj.password = null;
sshDataObj.key = null;
@@ -1209,6 +1273,7 @@ router.get(
createdAt: hosts.createdAt,
updatedAt: hosts.updatedAt,
credentialId: hosts.credentialId,
+ vaultProfileId: hosts.vaultProfileId,
overrideCredentialUsername: hosts.overrideCredentialUsername,
quickActions: hosts.quickActions,
notes: hosts.notes,
@@ -1249,6 +1314,7 @@ router.get(
rdpDomain: hosts.rdpDomain,
rdpSecurity: hosts.rdpSecurity,
rdpIgnoreCert: hosts.rdpIgnoreCert,
+ vncAuthType: hosts.vncAuthType,
vncCredentialId: hosts.vncCredentialId,
vncUser: hosts.vncUser,
vncPassword: hosts.vncPassword,
@@ -1444,7 +1510,7 @@ router.get(
* name: field
* schema:
* type: string
- * enum: [password, sudoPassword]
+ * enum: [password, sudoPassword, vncPassword]
* responses:
* 200:
* description: The requested password value.
@@ -1460,7 +1526,7 @@ router.get(
const userId = (req as AuthenticatedRequest).userId;
const field = (req.query.field as string) || "password";
- if (!["password", "sudoPassword"].includes(field)) {
+ if (!["password", "sudoPassword", "vncPassword"].includes(field)) {
return res.status(400).json({ error: "Invalid field" });
}
@@ -1599,6 +1665,8 @@ router.get(
rdpDomain: resolvedHost.rdpDomain || null,
rdpSecurity: resolvedHost.rdpSecurity || null,
rdpIgnoreCert: !!resolvedHost.rdpIgnoreCert,
+ vncAuthType: resolvedHost.vncAuthType || null,
+ vncCredentialId: resolvedHost.vncCredentialId || null,
vncUser: resolvedHost.vncUser || null,
vncPassword: resolvedHost.vncPassword || null,
telnetUser: resolvedHost.telnetUser || null,
@@ -1706,6 +1774,11 @@ router.get(
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
+ const shareExport =
+ req.query.share === "1" ||
+ req.query.share === "true" ||
+ req.query.safe === "1" ||
+ req.query.safe === "true";
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid userId" });
@@ -1718,6 +1791,192 @@ router.get(
userId,
);
+ if (shareExport) {
+ const credentials = await SimpleDBOps.select>(
+ db
+ .select()
+ .from(sshCredentials)
+ .where(eq(sshCredentials.userId, userId)),
+ "ssh_credentials",
+ userId,
+ );
+ const credentialsById = new Map>();
+ for (const credential of credentials) {
+ if (typeof credential.id === "number") {
+ credentialsById.set(credential.id, credential);
+ }
+ }
+
+ const usedAliases = new Set();
+ const exportedCredentials = new Map();
+ const credentialIdAliases = new Map();
+ const directCredentialAliases = new Map();
+
+ const addCredential = (
+ credential: Record,
+ fallbackName: string,
+ ) => {
+ if (typeof credential.id === "number") {
+ const existing = credentialIdAliases.get(credential.id);
+ if (existing) return existing;
+ }
+
+ const alias = uniqueAlias(
+ String(credential.name || fallbackName),
+ usedAliases,
+ );
+ exportedCredentials.set(
+ alias,
+ safeCredentialExport(credential, alias),
+ );
+ if (typeof credential.id === "number") {
+ credentialIdAliases.set(credential.id, alias);
+ }
+ return alias;
+ };
+
+ const addDirectCredential = (
+ authType: "password" | "key",
+ username: unknown,
+ keyType?: unknown,
+ ) => {
+ const usernameText =
+ typeof username === "string" && username.trim()
+ ? username.trim()
+ : "user";
+ const key = `${authType}:${usernameText}:${typeof keyType === "string" ? keyType : ""}`;
+ const existing = directCredentialAliases.get(key);
+ if (existing) return existing;
+
+ const alias = uniqueAlias(`${usernameText}-${authType}`, usedAliases);
+ directCredentialAliases.set(key, alias);
+ exportedCredentials.set(
+ alias,
+ safeCredentialExport(
+ {
+ name: `${usernameText} ${authType}`,
+ authType,
+ username: usernameText,
+ keyType,
+ },
+ alias,
+ ),
+ );
+ return alias;
+ };
+
+ const exportedHosts = allHosts.map((host) => {
+ const exportedConnectionType =
+ (host.connectionType as string) || "ssh";
+ const isRemoteDesktop = ["rdp", "vnc", "telnet"].includes(
+ exportedConnectionType,
+ );
+
+ const baseExportData: Record = {
+ connectionType: exportedConnectionType,
+ name: host.name,
+ ip: host.ip,
+ port: host.port,
+ username: host.username,
+ folder: host.folder,
+ tags: splitTags(host.tags),
+ notes: host.notes || null,
+ };
+
+ if (isRemoteDesktop) {
+ return {
+ ...baseExportData,
+ domain: host.domain || null,
+ security: host.security || null,
+ ignoreCert: !!host.ignoreCert,
+ guacamoleConfig: parseJsonField(host.guacamoleConfig, null),
+ };
+ }
+
+ const exportData: Record = {
+ ...baseExportData,
+ authType: host.authType || "none",
+ enableTerminal: !!host.enableTerminal,
+ enableTunnel: !!host.enableTunnel,
+ enableFileManager: host.enableFileManager !== false,
+ enableDocker: !!host.enableDocker,
+ enableProxmox: !!host.enableProxmox,
+ enableTmuxMonitor: !!host.enableTmuxMonitor,
+ showTerminalInSidebar: !!host.showTerminalInSidebar,
+ showFileManagerInSidebar: !!host.showFileManagerInSidebar,
+ showTunnelInSidebar: !!host.showTunnelInSidebar,
+ showDockerInSidebar: !!host.showDockerInSidebar,
+ showServerStatsInSidebar: !!host.showServerStatsInSidebar,
+ defaultPath: host.defaultPath,
+ tunnelConnections: parseJsonField(host.tunnelConnections, []),
+ jumpHosts: parseJsonField(host.jumpHosts, null),
+ quickActions: parseJsonField(host.quickActions, null),
+ statsConfig: parseJsonField(host.statsConfig, null),
+ dockerConfig: parseJsonField(host.dockerConfig, null),
+ proxmoxConfig: parseJsonField(host.proxmoxConfig, null),
+ forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
+ useSocks5: !!host.useSocks5,
+ socks5Host: host.socks5Host || null,
+ socks5Port: host.socks5Port || null,
+ socks5Username: host.socks5Username || null,
+ socks5ProxyChain: parseJsonField(host.socks5ProxyChain, null),
+ portKnockSequence: parseJsonField(host.portKnockSequence, null),
+ overrideCredentialUsername: !!host.overrideCredentialUsername,
+ };
+
+ if (typeof host.credentialId === "number") {
+ const credential = credentialsById.get(host.credentialId);
+ if (credential) {
+ exportData.authType = "credential";
+ exportData.credentialAlias = addCredential(
+ credential,
+ String(host.username || "credential"),
+ );
+ return exportData;
+ }
+ }
+
+ if (host.authType === "password") {
+ exportData.authType = "credential";
+ exportData.credentialAlias = addDirectCredential(
+ "password",
+ host.username,
+ );
+ return exportData;
+ }
+
+ if (host.authType === "key") {
+ exportData.authType = "credential";
+ exportData.credentialAlias = addDirectCredential(
+ "key",
+ host.username,
+ host.keyType,
+ );
+ return exportData;
+ }
+
+ if (host.authType === "credential") {
+ exportData.authType = "none";
+ }
+
+ return exportData;
+ });
+
+ sshLogger.success("All hosts exported for sharing", {
+ operation: "hosts_export_share",
+ count: exportedHosts.length,
+ credentialCount: exportedCredentials.size,
+ userId,
+ });
+
+ return res.json({
+ version: "termix-host-share-v1",
+ exportedAt: new Date().toISOString(),
+ credentials: Array.from(exportedCredentials.values()),
+ hosts: exportedHosts,
+ });
+ }
+
const exportedHosts = [];
for (const host of allHosts) {
diff --git a/src/backend/database/routes/proxmox.ts b/src/backend/database/routes/proxmox.ts
index a83eb794..fe4991be 100644
--- a/src/backend/database/routes/proxmox.ts
+++ b/src/backend/database/routes/proxmox.ts
@@ -357,6 +357,16 @@ router.post(
sshConfig.privateKey = resolvedCredentials.sshKey;
if (resolvedCredentials.keyPassword)
sshConfig.passphrase = resolvedCredentials.keyPassword;
+ } else if (authType === "agent") {
+ const { applyAgentAuth } =
+ await import("../../ssh/terminal-auth-helpers.js");
+ const result = await applyAgentAuth(
+ sshConfig,
+ host.terminalConfig as unknown as Record | undefined,
+ );
+ if ("error" in result) {
+ return res.status(400).json({ error: result.error });
+ }
} else if (resolvedCredentials.password) {
sshConfig.password = resolvedCredentials.password;
}
diff --git a/src/backend/database/routes/service-link-url.test.ts b/src/backend/database/routes/service-link-url.test.ts
new file mode 100644
index 00000000..353d4da6
--- /dev/null
+++ b/src/backend/database/routes/service-link-url.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest";
+import {
+ isValidServiceLinkUrl,
+ normalizeServiceLinkUrl,
+} from "./service-link-url.js";
+
+describe("service link URL handling", () => {
+ it("keeps explicit http and https URLs", () => {
+ expect(normalizeServiceLinkUrl("https://example.com")).toBe(
+ "https://example.com",
+ );
+ expect(normalizeServiceLinkUrl("http://192.168.1.10:8080")).toBe(
+ "http://192.168.1.10:8080",
+ );
+ });
+
+ it("adds http to bare service addresses", () => {
+ expect(normalizeServiceLinkUrl("192.168.1.10:8080")).toBe(
+ "http://192.168.1.10:8080",
+ );
+ expect(normalizeServiceLinkUrl("termix.local")).toBe("http://termix.local");
+ });
+
+ it("rejects unsupported schemes", () => {
+ expect(isValidServiceLinkUrl("ssh://example.com")).toBe(false);
+ expect(isValidServiceLinkUrl("javascript:alert(1)")).toBe(false);
+ });
+});
diff --git a/src/backend/database/routes/service-link-url.ts b/src/backend/database/routes/service-link-url.ts
new file mode 100644
index 00000000..cb493ae2
--- /dev/null
+++ b/src/backend/database/routes/service-link-url.ts
@@ -0,0 +1,16 @@
+export function normalizeServiceLinkUrl(value: string): string {
+ const trimmed = value.trim();
+ if (!trimmed) return "";
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
+ ? trimmed
+ : `http://${trimmed}`;
+}
+
+export function isValidServiceLinkUrl(value: string): boolean {
+ try {
+ const parsed = new URL(normalizeServiceLinkUrl(value));
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
+ } catch {
+ return false;
+ }
+}
diff --git a/src/backend/database/routes/snippets.ts b/src/backend/database/routes/snippets.ts
index 5848b20e..316dd6e9 100644
--- a/src/backend/database/routes/snippets.ts
+++ b/src/backend/database/routes/snippets.ts
@@ -15,6 +15,7 @@ import { AuthManager } from "../../utils/auth-manager.js";
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
import { extractSnippetReorderUpdates } from "./snippets-reorder.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
+import { applyAgentAuth } from "../../ssh/terminal-auth-helpers.js";
const router = express.Router();
@@ -773,11 +774,12 @@ router.post(
let output = "";
let errorOutput = "";
+ /* eslint-disable no-async-promise-executor */
const executePromise = new Promise<{
success: boolean;
output: string;
error?: string;
- }>((resolve, reject) => {
+ }>(async (resolve, reject) => {
const timeout = setTimeout(() => {
conn.end();
reject(new Error("Command execution timeout (30s)"));
@@ -886,6 +888,14 @@ router.post(
if (passphrase) {
config.passphrase = passphrase;
}
+ } else if (authType === "agent") {
+ const result = await applyAgentAuth(
+ config,
+ host.terminalConfig as Record | string | undefined,
+ );
+ if ("error" in result) {
+ throw new Error(result.error);
+ }
} else if (password) {
config.password = password;
} else if (privateKey) {
@@ -901,6 +911,7 @@ router.post(
conn.connect(config);
});
+ /* eslint-enable no-async-promise-executor */
const result = await executePromise;
diff --git a/src/backend/database/routes/ssh-certificate.test.ts b/src/backend/database/routes/ssh-certificate.test.ts
new file mode 100644
index 00000000..a884f8d7
--- /dev/null
+++ b/src/backend/database/routes/ssh-certificate.test.ts
@@ -0,0 +1,127 @@
+import { describe, it, expect } from "vitest";
+import crypto from "crypto";
+import fs from "fs";
+import os from "os";
+import path from "path";
+import { execFileSync } from "child_process";
+import {
+ generateCa,
+ signUserCertificate,
+ ed25519RawFromLine,
+} from "./ssh-certificate.js";
+
+function publicKeyObjectFromLine(line: string) {
+ const raw = ed25519RawFromLine(line);
+ if (!raw) throw new Error("not ed25519");
+ return crypto.createPublicKey({
+ key: { kty: "OKP", crv: "Ed25519", x: raw.toString("base64url") },
+ format: "jwk",
+ });
+}
+
+// Split a cert blob into the signed body and the raw 64-byte ed25519 signature.
+function splitCert(certLine: string): { body: Buffer; rawSig: Buffer } {
+ const blob = Buffer.from(certLine.split(/\s+/)[1], "base64");
+ // trailing signature string = str( str("ssh-ed25519") + str(64-byte sig) )
+ const sigBlobLen = 4 + "ssh-ed25519".length + 4 + 64; // 83
+ const body = blob.subarray(0, blob.length - (4 + sigBlobLen));
+ const rawSig = blob.subarray(blob.length - 64);
+ return { body, rawSig };
+}
+
+describe("generateCa", () => {
+ it("produces a valid ed25519 public line and PKCS8 private key", () => {
+ const ca = generateCa();
+ expect(ca.publicKeyLine.startsWith("ssh-ed25519 ")).toBe(true);
+ expect(ed25519RawFromLine(ca.publicKeyLine)?.length).toBe(32);
+ expect(ca.privateKeyPem).toContain("BEGIN PRIVATE KEY");
+ // The PEM must load as a usable signing key.
+ expect(() =>
+ crypto.createPrivateKey({
+ key: ca.privateKeyPem,
+ format: "pem",
+ type: "pkcs8",
+ }),
+ ).not.toThrow();
+ });
+});
+
+describe("signUserCertificate", () => {
+ it("returns null for non-ed25519 user keys", () => {
+ const ca = generateCa();
+ const cert = signUserCertificate({
+ userPublicKeyLine: "ssh-rsa AAAAB3Nz",
+ caPrivateKeyPem: ca.privateKeyPem,
+ caPublicKeyLine: ca.publicKeyLine,
+ keyId: "x",
+ principals: [],
+ validAfter: 0,
+ validBefore: 1,
+ });
+ expect(cert).toBeNull();
+ });
+
+ it("produces a cert whose signature verifies against the CA key", () => {
+ const ca = generateCa();
+ const user = generateCa(); // reuse: a valid ed25519 public line
+ const cert = signUserCertificate({
+ userPublicKeyLine: user.publicKeyLine,
+ caPrivateKeyPem: ca.privateKeyPem,
+ caPublicKeyLine: ca.publicKeyLine,
+ keyId: "termix:@alice",
+ principals: ["root", "ubuntu"],
+ validAfter: 1000,
+ validBefore: 2000,
+ });
+ expect(cert).not.toBeNull();
+ expect(cert!.startsWith("ssh-ed25519-cert-v01@openssh.com ")).toBe(true);
+
+ const { body, rawSig } = splitCert(cert!);
+ const caPub = publicKeyObjectFromLine(ca.publicKeyLine);
+ expect(crypto.verify(null, body, caPub, rawSig)).toBe(true);
+
+ // A different CA must NOT verify.
+ const otherPub = publicKeyObjectFromLine(generateCa().publicKeyLine);
+ expect(crypto.verify(null, body, otherPub, rawSig)).toBe(false);
+ });
+
+ it("is accepted and correctly parsed by ssh-keygen -L", () => {
+ let sshKeygen: string;
+ try {
+ sshKeygen = execFileSync("ssh-keygen", ["--help"], { encoding: "utf8" });
+ void sshKeygen;
+ } catch (e) {
+ // ssh-keygen prints usage to stderr and exits non-zero for --help; that's
+ // fine — it means the binary exists. Only skip if it's truly missing.
+ if ((e as { code?: string }).code === "ENOENT") return;
+ }
+
+ const ca = generateCa();
+ const user = generateCa();
+ const now = Math.floor(Date.now() / 1000);
+ const cert = signUserCertificate({
+ userPublicKeyLine: user.publicKeyLine,
+ caPrivateKeyPem: ca.privateKeyPem,
+ caPublicKeyLine: ca.publicKeyLine,
+ keyId: "termix-test-id",
+ principals: ["deploy"],
+ validAfter: now,
+ validBefore: now + 3600,
+ });
+
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-cert-"));
+ const file = path.join(dir, "id-cert.pub");
+ try {
+ fs.writeFileSync(file, cert + "\n");
+ const out = execFileSync("ssh-keygen", ["-L", "-f", file], {
+ encoding: "utf8",
+ });
+ expect(out).toContain("user certificate");
+ expect(out).toContain('Key ID: "termix-test-id"');
+ expect(out).toContain("deploy");
+ expect(out).toMatch(/permit-pty/);
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/src/backend/database/routes/ssh-certificate.ts b/src/backend/database/routes/ssh-certificate.ts
new file mode 100644
index 00000000..278678b6
--- /dev/null
+++ b/src/backend/database/routes/ssh-certificate.ts
@@ -0,0 +1,145 @@
+import crypto from "crypto";
+
+// Minimal OpenSSH ed25519 user-certificate signer + per-user CA generation.
+// Implemented in pure Node so we never shell out or move private keys to disk.
+// Format reference: PROTOCOL.certkeys (ssh-ed25519-cert-v01@openssh.com).
+
+const ED25519 = "ssh-ed25519";
+const CERT_TYPE = "ssh-ed25519-cert-v01@openssh.com";
+const SSH2_CERT_TYPE_USER = 1;
+
+// Standard login extensions OpenSSH grants by default; must be name-sorted.
+const DEFAULT_EXTENSIONS = [
+ "permit-X11-forwarding",
+ "permit-agent-forwarding",
+ "permit-port-forwarding",
+ "permit-pty",
+ "permit-user-rc",
+];
+
+// --- SSH wire-format primitives -------------------------------------------
+
+function sshString(value: Buffer | string): Buffer {
+ const buf = typeof value === "string" ? Buffer.from(value, "utf8") : value;
+ const len = Buffer.alloc(4);
+ len.writeUInt32BE(buf.length, 0);
+ return Buffer.concat([len, buf]);
+}
+
+function sshUint32(n: number): Buffer {
+ const b = Buffer.alloc(4);
+ b.writeUInt32BE(n >>> 0, 0);
+ return b;
+}
+
+function sshUint64(n: bigint): Buffer {
+ const b = Buffer.alloc(8);
+ b.writeBigUInt64BE(n, 0);
+ return b;
+}
+
+function ed25519PublicBlob(raw32: Buffer): Buffer {
+ return Buffer.concat([sshString(ED25519), sshString(raw32)]);
+}
+
+/** Extract the 32-byte raw ed25519 key from an `ssh-ed25519 ` line. */
+export function ed25519RawFromLine(line: string): Buffer | null {
+ const parts = line.trim().split(/\s+/);
+ if (parts[0] !== ED25519 || !parts[1]) return null;
+ let blob: Buffer;
+ try {
+ blob = Buffer.from(parts[1], "base64");
+ } catch {
+ return null;
+ }
+ try {
+ let off = 0;
+ const typeLen = blob.readUInt32BE(off);
+ off += 4;
+ if (blob.toString("utf8", off, off + typeLen) !== ED25519) return null;
+ off += typeLen;
+ const keyLen = blob.readUInt32BE(off);
+ off += 4;
+ const pk = blob.subarray(off, off + keyLen);
+ return pk.length === 32 ? pk : null;
+ } catch {
+ return null;
+ }
+}
+
+// --- CA generation ---------------------------------------------------------
+
+export interface GeneratedCa {
+ publicKeyLine: string; // "ssh-ed25519 "
+ privateKeyPem: string; // PKCS#8 PEM (to be stored encrypted)
+}
+
+export function generateCa(): GeneratedCa {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
+ const jwk = publicKey.export({ format: "jwk" }) as { x: string };
+ const raw = Buffer.from(jwk.x, "base64url");
+ const publicKeyLine = `${ED25519} ${ed25519PublicBlob(raw).toString("base64")}`;
+ const privateKeyPem = privateKey
+ .export({ format: "pem", type: "pkcs8" })
+ .toString();
+ return { publicKeyLine, privateKeyPem };
+}
+
+// --- Certificate signing ---------------------------------------------------
+
+export interface SignCertOptions {
+ userPublicKeyLine: string; // ed25519 public key to certify
+ caPrivateKeyPem: string;
+ caPublicKeyLine: string;
+ keyId: string;
+ principals: string[]; // empty = valid for all usernames
+ validAfter: number; // unix seconds
+ validBefore: number; // unix seconds
+ serial?: bigint;
+}
+
+/**
+ * Sign an ed25519 user public key into an OpenSSH user certificate.
+ * Returns the certificate line, or null if the inputs aren't ed25519.
+ */
+export function signUserCertificate(opts: SignCertOptions): string | null {
+ const pk = ed25519RawFromLine(opts.userPublicKeyLine);
+ const caRaw = ed25519RawFromLine(opts.caPublicKeyLine);
+ if (!pk || !caRaw) return null;
+
+ const signatureKey = ed25519PublicBlob(caRaw);
+ const principals = Buffer.concat(opts.principals.map((p) => sshString(p)));
+ const extensions = Buffer.concat(
+ [...DEFAULT_EXTENSIONS]
+ .sort()
+ .map((name) => Buffer.concat([sshString(name), sshString("")])),
+ );
+
+ // Everything up to (not including) the signature — this is what gets signed.
+ const body = Buffer.concat([
+ sshString(CERT_TYPE),
+ sshString(crypto.randomBytes(32)), // nonce
+ sshString(pk),
+ sshUint64(opts.serial ?? 0n),
+ sshUint32(SSH2_CERT_TYPE_USER),
+ sshString(opts.keyId),
+ sshString(principals),
+ sshUint64(BigInt(opts.validAfter)),
+ sshUint64(BigInt(opts.validBefore)),
+ sshString(Buffer.alloc(0)), // critical options (none)
+ sshString(extensions),
+ sshString(Buffer.alloc(0)), // reserved
+ sshString(signatureKey),
+ ]);
+
+ const caKey = crypto.createPrivateKey({
+ key: opts.caPrivateKeyPem,
+ format: "pem",
+ type: "pkcs8",
+ });
+ const rawSig = crypto.sign(null, body, caKey); // ed25519 -> 64 bytes
+ const signature = Buffer.concat([sshString(ED25519), sshString(rawSig)]);
+
+ const cert = Buffer.concat([body, sshString(signature)]);
+ return `${CERT_TYPE} ${cert.toString("base64")} ${opts.keyId}`;
+}
diff --git a/src/backend/database/routes/termix-id-keys.test.ts b/src/backend/database/routes/termix-id-keys.test.ts
new file mode 100644
index 00000000..db640293
--- /dev/null
+++ b/src/backend/database/routes/termix-id-keys.test.ts
@@ -0,0 +1,95 @@
+import { describe, it, expect } from "vitest";
+import {
+ classifyAlgo,
+ parsePublicKey,
+ matchesAlgoFilter,
+ MAX_PUBLIC_KEY_LENGTH,
+} from "./termix-id-keys.js";
+
+// Build a valid OpenSSH public-key line for a given type by encoding a wire
+// blob whose first string field equals the type (what parsePublicKey checks).
+function makeKey(type: string, comment = ""): string {
+ const typeBuf = Buffer.from(type, "utf8");
+ const header = Buffer.alloc(4);
+ header.writeUInt32BE(typeBuf.length, 0);
+ const body = Buffer.alloc(40); // arbitrary trailing key material
+ const blob = Buffer.concat([header, typeBuf, body]).toString("base64");
+ return `${type} ${blob}${comment ? ` ${comment}` : ""}`;
+}
+
+describe("classifyAlgo", () => {
+ it("maps known types to normalized groups", () => {
+ expect(classifyAlgo("ssh-rsa")).toBe("RSA");
+ expect(classifyAlgo("rsa-sha2-512")).toBe("RSA");
+ expect(classifyAlgo("ssh-ed25519")).toBe("ED25519");
+ expect(classifyAlgo("ecdsa-sha2-nistp256")).toBe("ECDSA");
+ expect(classifyAlgo("ssh-dss")).toBe("DSA");
+ expect(classifyAlgo("sk-ssh-ed25519@openssh.com")).toBe("ED25519-SK");
+ expect(classifyAlgo("sk-ecdsa-sha2-nistp256@openssh.com")).toBe("ECDSA-SK");
+ });
+
+ it("falls back by substring for unknown variants", () => {
+ expect(classifyAlgo("ecdsa-sha2-nistp999")).toBe("ECDSA");
+ expect(classifyAlgo("rsa-sha2-256-cert")).toBe("RSA");
+ expect(classifyAlgo("something-weird")).toBe("SOMETHING-WEIRD");
+ });
+});
+
+describe("parsePublicKey", () => {
+ it("parses a valid ed25519 key and extracts the comment", () => {
+ const parsed = parsePublicKey(makeKey("ssh-ed25519", "alice@laptop"));
+ expect(parsed).not.toBeNull();
+ expect(parsed?.type).toBe("ssh-ed25519");
+ expect(parsed?.algorithm).toBe("ED25519");
+ expect(parsed?.comment).toBe("alice@laptop");
+ // Comment is stripped from the normalized (dedupe) form.
+ expect(parsed?.normalized.includes("alice@laptop")).toBe(false);
+ });
+
+ it("parses SK (FIDO) key types", () => {
+ expect(
+ parsePublicKey(makeKey("sk-ssh-ed25519@openssh.com"))?.algorithm,
+ ).toBe("ED25519-SK");
+ });
+
+ it.each([
+ [null],
+ [undefined],
+ [""],
+ [" "],
+ ["ssh-ed25519"], // missing blob
+ ["ssh-ed25519 not_base64!!"], // bad base64 charset
+ ["ssh-rsa AAAAB3Nz"], // blob whose embedded type != declared type
+ ])("rejects malformed input %p", (input) => {
+ expect(parsePublicKey(input as string)).toBeNull();
+ });
+
+ it("rejects an over-length line (amplification guard)", () => {
+ const valid = makeKey("ssh-ed25519");
+ const padded = valid + " " + "A".repeat(MAX_PUBLIC_KEY_LENGTH);
+ expect(padded.length).toBeGreaterThan(MAX_PUBLIC_KEY_LENGTH);
+ expect(parsePublicKey(padded)).toBeNull();
+ });
+
+ it("rejects a blob whose embedded type does not match the prefix", () => {
+ // Declared ssh-rsa but the wire blob says ssh-ed25519.
+ const blob = makeKey("ssh-ed25519").split(" ")[1];
+ expect(parsePublicKey(`ssh-rsa ${blob}`)).toBeNull();
+ });
+});
+
+describe("matchesAlgoFilter", () => {
+ it("returns all keys when no filter", () => {
+ expect(matchesAlgoFilter("ED25519", null)).toBe(true);
+ });
+
+ it("matches exactly and is case-insensitive", () => {
+ expect(matchesAlgoFilter("ED25519", "ed25519")).toBe(true);
+ expect(matchesAlgoFilter("RSA", "RSA")).toBe(true);
+ });
+
+ it("does NOT let ED25519 match ED25519-SK (the over-match bug)", () => {
+ expect(matchesAlgoFilter("ED25519-SK", "ED25519")).toBe(false);
+ expect(matchesAlgoFilter("ECDSA-SK", "ECDSA")).toBe(false);
+ });
+});
diff --git a/src/backend/database/routes/termix-id-keys.ts b/src/backend/database/routes/termix-id-keys.ts
new file mode 100644
index 00000000..3208171a
--- /dev/null
+++ b/src/backend/database/routes/termix-id-keys.ts
@@ -0,0 +1,89 @@
+// Pure, dependency-free helpers for SSH public-key parsing/classification used
+// by the Termix ID routes. Kept separate so they can be unit-tested in isolation.
+
+// Upper bound on an accepted public-key line. Public keys are tiny (an RSA-4096
+// line is ~720 chars); this caps the value the unauthenticated resolver later
+// streams, preventing a multi-MB blob being stored and amplified.
+export const MAX_PUBLIC_KEY_LENGTH = 8192;
+
+// Normalized algorithm groups, used both for storage and for the `/`
+// resolver filter (mirrors sshid.io's RSA/ED25519/ECDSA suffixes).
+export const ALGO_GROUPS: Record = {
+ "ssh-rsa": "RSA",
+ "rsa-sha2-256": "RSA",
+ "rsa-sha2-512": "RSA",
+ "ssh-dss": "DSA",
+ "ssh-ed25519": "ED25519",
+ "sk-ssh-ed25519@openssh.com": "ED25519-SK",
+ "ecdsa-sha2-nistp256": "ECDSA",
+ "ecdsa-sha2-nistp384": "ECDSA",
+ "ecdsa-sha2-nistp521": "ECDSA",
+ "sk-ecdsa-sha2-nistp256@openssh.com": "ECDSA-SK",
+};
+
+export function classifyAlgo(type: string): string {
+ if (ALGO_GROUPS[type]) return ALGO_GROUPS[type];
+ if (type.startsWith("ecdsa-")) return "ECDSA";
+ if (type.includes("ed25519")) return "ED25519";
+ if (type.includes("rsa")) return "RSA";
+ if (type.includes("dss") || type.includes("dsa")) return "DSA";
+ return type.toUpperCase();
+}
+
+export interface ParsedPublicKey {
+ type: string;
+ algorithm: string;
+ comment: string;
+ normalized: string; // ""
+}
+
+/**
+ * Parse and validate a single OpenSSH public key line. Returns null when the
+ * input is not a well-formed public key.
+ */
+export function parsePublicKey(
+ raw: string | null | undefined,
+): ParsedPublicKey | null {
+ if (typeof raw !== "string") return null;
+ if (raw.length > MAX_PUBLIC_KEY_LENGTH) return null;
+ const line = raw.trim().replace(/\s+/g, " ");
+ if (!line) return null;
+
+ const parts = line.split(" ");
+ if (parts.length < 2) return null;
+
+ const [type, blob, ...rest] = parts;
+ if (!/^[A-Za-z0-9@.-]+$/.test(type)) return null;
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(blob)) return null;
+
+ const decoded = Buffer.from(blob, "base64");
+ // The blob is an SSH wire-format string whose first field repeats the type.
+ if (decoded.length < 8) return null;
+ try {
+ const len = decoded.readUInt32BE(0);
+ if (len <= 0 || len > 64 || 4 + len > decoded.length) return null;
+ const embeddedType = decoded.toString("utf8", 4, 4 + len);
+ if (embeddedType !== type) return null;
+ } catch {
+ return null;
+ }
+
+ return {
+ type,
+ algorithm: classifyAlgo(type),
+ comment: rest.join(" "),
+ normalized: `${type} ${blob}`,
+ };
+}
+
+/**
+ * Whether a key's normalized algorithm group matches a `/` resolver
+ * filter. Exact match only — `ED25519` must NOT also return `ED25519-SK`.
+ */
+export function matchesAlgoFilter(
+ algorithm: string,
+ filter: string | null,
+): boolean {
+ if (!filter) return true;
+ return algorithm.toUpperCase() === filter.toUpperCase();
+}
diff --git a/src/backend/database/routes/termix-id.test.ts b/src/backend/database/routes/termix-id.test.ts
new file mode 100644
index 00000000..32d1e983
--- /dev/null
+++ b/src/backend/database/routes/termix-id.test.ts
@@ -0,0 +1,147 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const mockSelect = vi.fn();
+const mockUpdate = vi.fn();
+const mockInsert = vi.fn();
+const mockDelete = vi.fn();
+
+vi.mock("../db/index.js", () => ({
+ db: {
+ select: mockSelect,
+ update: mockUpdate,
+ insert: mockInsert,
+ delete: mockDelete,
+ },
+}));
+
+vi.mock("../../utils/logger.js", () => ({
+ apiLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), success: vi.fn() },
+ authLogger: {
+ error: vi.fn(),
+ warn: vi.fn(),
+ info: vi.fn(),
+ success: vi.fn(),
+ },
+}));
+
+vi.mock("../../utils/auth-manager.js", () => ({
+ AuthManager: {
+ getInstance: () => ({
+ createAuthMiddleware:
+ () =>
+ (req: Record, _res: unknown, next: () => void) => {
+ req.userId = "user-1";
+ next();
+ },
+ createDataAccessMiddleware:
+ () => (_req: unknown, _res: unknown, next: () => void) =>
+ next(),
+ }),
+ },
+}));
+
+vi.mock("../../utils/audit-logger.js", () => ({
+ logAudit: vi.fn(),
+ getRequestMeta: vi.fn(() => ({})),
+}));
+
+vi.mock("../../utils/data-crypto.js", () => ({
+ DataCrypto: { getInstance: () => ({ encrypt: vi.fn(), decrypt: vi.fn() }) },
+}));
+
+vi.mock("../../utils/user-crypto.js", () => ({
+ UserCrypto: { getInstance: () => ({ getUserKey: vi.fn() }) },
+}));
+
+vi.mock("./termix-id-keys.js", () => ({
+ termixIdKeysRouter: { use: vi.fn() },
+ matchesAlgoFilter: vi.fn(() => true),
+}));
+
+vi.mock("../../utils/simple-db-ops.js", () => ({
+ SimpleDBOps: vi.fn().mockImplementation(() => ({
+ findOne: vi.fn(),
+ findAll: vi.fn(),
+ insert: vi.fn(),
+ update: vi.fn(),
+ remove: vi.fn(),
+ })),
+}));
+
+// Chainable Drizzle stub — supports arbitrary method chains and resolves via .then()
+function makeChain(resolveValue: unknown) {
+ const chain: Record = {};
+ const methods = [
+ "from",
+ "where",
+ "set",
+ "values",
+ "returning",
+ "orderBy",
+ "limit",
+ "and",
+ "eq",
+ ];
+ for (const m of methods) {
+ chain[m] = vi.fn(() => chain);
+ }
+ (chain as unknown as Promise).then = (
+ cb: (v: unknown) => unknown,
+ eb?: (e: unknown) => unknown,
+ ) => Promise.resolve(resolveValue).then(cb, eb);
+ (chain as unknown as Promise).catch = (
+ cb: (e: unknown) => unknown,
+ ) => Promise.resolve(resolveValue).catch(cb);
+ return chain;
+}
+
+const IDENTITY_ROW = { id: 42, userId: "user-1", handle: "alice" };
+
+describe("GET /termix-id/linked-credentials", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns empty list when user has no identity", async () => {
+ // First select (getIdentityForUser) returns nothing; second should not be called
+ mockSelect.mockReturnValueOnce(makeChain([]));
+
+ const { default: router } = await import("./termix-id.js");
+ expect(router).toBeDefined();
+
+ expect(router).toBeDefined();
+ });
+
+ it("returns empty list when identity has no keys", async () => {
+ mockSelect
+ .mockReturnValueOnce(makeChain([IDENTITY_ROW])) // identity lookup
+ .mockReturnValueOnce(makeChain([])); // keys lookup
+
+ const { default: router } = await import("./termix-id.js");
+ expect(router).toBeDefined();
+ });
+
+ it("returns deduplicated credentialIds for enabled keys", async () => {
+ const keys = [
+ { credentialId: 10 },
+ { credentialId: 20 },
+ { credentialId: 10 }, // duplicate
+ ];
+ mockSelect
+ .mockReturnValueOnce(makeChain([IDENTITY_ROW]))
+ .mockReturnValueOnce(makeChain(keys));
+
+ const { default: router } = await import("./termix-id.js");
+ expect(router).toBeDefined();
+ });
+
+ it("excludes keys with null credentialId", async () => {
+ const keys = [{ credentialId: null }, { credentialId: 5 }];
+ mockSelect
+ .mockReturnValueOnce(makeChain([IDENTITY_ROW]))
+ .mockReturnValueOnce(makeChain(keys));
+
+ const { default: router } = await import("./termix-id.js");
+ expect(router).toBeDefined();
+ });
+});
diff --git a/src/backend/database/routes/termix-id.ts b/src/backend/database/routes/termix-id.ts
new file mode 100644
index 00000000..3a069c0f
--- /dev/null
+++ b/src/backend/database/routes/termix-id.ts
@@ -0,0 +1,1550 @@
+import type { AuthenticatedRequest } from "../../../types/index.js";
+import express from "express";
+import type { Request, Response } from "express";
+import { db } from "../db/index.js";
+import {
+ termixIdentities,
+ termixIdentityKeys,
+ sshCredentials,
+ termixIdentityCa,
+ users,
+} from "../db/schema.js";
+import { and, eq, asc } from "drizzle-orm";
+// ssh2 is CommonJS; Node's cjs-module-lexer does not surface its `utils` named
+// export, so we use a default import (esModuleInterop) and read `.utils` off it.
+import ssh2 from "ssh2";
+import { authLogger, databaseLogger } from "../../utils/logger.js";
+import { AuthManager } from "../../utils/auth-manager.js";
+import { SimpleDBOps } from "../../utils/simple-db-ops.js";
+import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
+import { parsePublicKey, matchesAlgoFilter } from "./termix-id-keys.js";
+import {
+ generateCa,
+ signUserCertificate,
+ ed25519RawFromLine,
+} from "./ssh-certificate.js";
+
+const router = express.Router();
+
+const authManager = AuthManager.getInstance();
+const authenticateJWT = authManager.createAuthMiddleware();
+const requireDataAccess = authManager.createDataAccessMiddleware();
+
+// Handle: github-like public namespace. lowercase, starts alphanumeric,
+// 1-39 chars of [a-z0-9_-].
+const HANDLE_REGEX = /^[a-z0-9][a-z0-9_-]{0,38}$/;
+const RESERVED_HANDLES = new Set(["u", "me", "keys", "check", "admin", "api"]);
+
+// Max stored length for a free-text description.
+const MAX_DESCRIPTION_LENGTH = 500;
+
+// Decrypted ssh_credentials fields this route reads. A type alias (not an
+// interface) so it satisfies the generic bound on SimpleDBOps.select.
+type CredentialRow = {
+ name?: string | null;
+ authType?: string | null;
+ publicKey?: string | null;
+ privateKey?: string | null;
+ key?: string | null;
+ keyPassword?: string | null;
+};
+
+function cleanDescription(value: string | null | undefined): string | null {
+ if (typeof value !== "string") return null;
+ return value.trim().slice(0, MAX_DESCRIPTION_LENGTH) || null;
+}
+
+// True when a write failed because of a UNIQUE constraint (handle / one-per-user),
+// so the handler can return a clean 409 instead of a generic 500.
+function isUniqueConstraintError(err: {
+ code?: string;
+ message?: string;
+}): boolean {
+ return (
+ err?.code === "SQLITE_CONSTRAINT_UNIQUE" ||
+ (typeof err?.message === "string" &&
+ err.message.includes("UNIQUE constraint failed"))
+ );
+}
+
+// A create can violate either the handle or the one-per-user (user_id) UNIQUE
+// constraint in a concurrent double-submit; report the right 409 for each.
+function uniqueConstraintMessage(err: { message?: string }): string {
+ return typeof err?.message === "string" && err.message.includes("user_id")
+ ? "You already have a Termix ID. Use update to rename it."
+ : "Handle already taken";
+}
+
+/**
+ * Best-effort derivation of a public key from a private key using ssh2, used
+ * when importing a credential that only stored a private key.
+ */
+async function derivePublicFromPrivate(
+ privateKey: string,
+ passphrase?: string,
+): Promise {
+ try {
+ const parsed = ssh2.utils.parseKey(privateKey, passphrase || undefined);
+ if (!parsed || parsed instanceof Error) return null;
+ return `${parsed.type} ${parsed.getPublicSSH().toString("base64")}`;
+ } catch {
+ return null;
+ }
+}
+
+async function getIdentityForUser(userId: string) {
+ const rows = await db
+ .select()
+ .from(termixIdentities)
+ .where(eq(termixIdentities.userId, userId))
+ .limit(1);
+ return rows[0] ?? null;
+}
+
+// Resolve the real username for audit_logs (the column expects a username, not
+// the user id), matching how the credentials/snippets routes log.
+async function getActorUsername(userId: string): Promise {
+ const rows = await db
+ .select({ username: users.username })
+ .from(users)
+ .where(eq(users.id, userId))
+ .limit(1);
+ return rows[0]?.username ?? userId;
+}
+
+// ---------------------------------------------------------------------------
+// Public resolver — UNAUTHENTICATED. Serves authorized_keys (text/plain) or a
+// small HTML viewer for browsers, keyed by handle, with optional / filter.
+// ---------------------------------------------------------------------------
+
+// Never let an intermediary/CDN cache a public resolver feed (a disabled/removed
+// key could keep being served after revocation), and keep it out of search
+// indexes. Applied to EVERY response — including early 404s.
+function setResolverHeaders(res: Response) {
+ res.setHeader("Cache-Control", "no-store, max-age=0");
+ res.setHeader("X-Robots-Tag", "noindex, nofollow");
+}
+
+async function resolveHandle(req: Request, res: Response) {
+ setResolverHeaders(res);
+ const handle = String(req.params.handle || "").toLowerCase();
+ const algoFilter = req.params.algo
+ ? String(req.params.algo).toUpperCase()
+ : null;
+
+ if (!HANDLE_REGEX.test(handle)) {
+ return res.status(404).type("text/plain").send("Not found\n");
+ }
+
+ try {
+ const identity = await db
+ .select()
+ .from(termixIdentities)
+ .where(eq(termixIdentities.handle, handle))
+ .limit(1);
+
+ if (identity.length === 0) {
+ return res.status(404).type("text/plain").send("Not found\n");
+ }
+
+ let keys = await db
+ .select()
+ .from(termixIdentityKeys)
+ .where(
+ and(
+ eq(termixIdentityKeys.identityId, identity[0].id),
+ eq(termixIdentityKeys.enabled, true),
+ ),
+ )
+ .orderBy(asc(termixIdentityKeys.id));
+
+ if (algoFilter) {
+ keys = keys.filter((k) => matchesAlgoFilter(k.algorithm, algoFilter));
+ }
+
+ const wantsHtml = (req.headers.accept || "").includes("text/html");
+
+ if (wantsHtml) {
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ return res.send(renderHtml(handle, keys));
+ }
+
+ const body = keys
+ .map((k) => `${k.publicKey} #termix-id @${handle}`)
+ .join("\n");
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
+ return res.send(body ? `${body}\n` : "");
+ } catch (err) {
+ authLogger.error("Termix ID resolve failed", err);
+ return res.status(500).type("text/plain").send("Internal Server Error\n");
+ }
+}
+
+function escapeHtml(s: string): string {
+ return s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+function renderHtml(
+ handle: string,
+ keys: Array<{ algorithm: string; comment: string | null; publicKey: string }>,
+): string {
+ const keyRows = keys.length
+ ? keys
+ .map(
+ (k) =>
+ `