* Feature request network graph

* Fixing PR442:

- Fixed:
    - UI design elemets
    - UI and button colors
    - JSON export
    - recent activity is default again
- Removed:
    - Online/Offline UI labels
    - left-click menu on hosts
- Added:
    - small pulsing dot inside the hosts to indicate online status like in the left bar

* fix: electron build errors and skip macos job

* fix: testflight submit failure

* fix: made submit job match build type

* fix: resolve Vite build warnings for mixed static/dynamic imports (#473)

* Update Crowdin configuration file

* Update Crowdin configuration file

* fix: resolve Vite build warnings for mixed static/dynamic imports

- Convert all dynamic imports of main-axios.ts to static imports (10 files)
- Convert all dynamic imports of sonner to static imports (4 files)
- Add manual chunking configuration to vite.config.ts for better bundle splitting
  - react-vendor: React and React DOM
  - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge
  - monaco: Monaco Editor
  - codemirror: CodeMirror and related packages
- Increase chunkSizeWarningLimit to 1000kB

This resolves Vite warnings about mixed import strategies preventing
proper code-splitting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>

* fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent)

* fix: build error on docker

* Handle enter button (#481)

* Update Crowdin configuration file

* Update Crowdin configuration file

* Update Linux Portable section with AUR link (#474)

* fix: file manager incorrectly decoding/encoding when editing files (#476)

* fix: electron build errors and skip macos job

* fix: testflight submit failure

* fix: made submit job match build type

* fix: resolve Vite build warnings for mixed static/dynamic imports (#473)

* Update Crowdin configuration file

* Update Crowdin configuration file

* fix: resolve Vite build warnings for mixed static/dynamic imports

- Convert all dynamic imports of main-axios.ts to static imports (10 files)
- Convert all dynamic imports of sonner to static imports (4 files)
- Add manual chunking configuration to vite.config.ts for better bundle splitting
  - react-vendor: React and React DOM
  - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge
  - monaco: Monaco Editor
  - codemirror: CodeMirror and related packages
- Increase chunkSizeWarningLimit to 1000kB

This resolves Vite warnings about mixed import strategies preventing
proper code-splitting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>

* fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent)

---------

Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>

* fix: build error on docker (#477)

* fix: electron build errors and skip macos job

* fix: testflight submit failure

* fix: made submit job match build type

* fix: resolve Vite build warnings for mixed static/dynamic imports (#473)

* Update Crowdin configuration file

* Update Crowdin configuration file

* fix: resolve Vite build warnings for mixed static/dynamic imports

- Convert all dynamic imports of main-axios.ts to static imports (10 files)
- Convert all dynamic imports of sonner to static imports (4 files)
- Add manual chunking configuration to vite.config.ts for better bundle splitting
  - react-vendor: React and React DOM
  - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge
  - monaco: Monaco Editor
  - codemirror: CodeMirror and related packages
- Increase chunkSizeWarningLimit to 1000kB

This resolves Vite warnings about mixed import strategies preventing
proper code-splitting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>

* fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent)

* fix: build error on docker

---------

Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>

* Increase max old space size for npm builds

* Increase Node.js memory limit in Dockerfile

* Remove NODE_OPTIONS from build commands in Dockerfile

* Change runner to blacksmith-4vcpu-ubuntu-2404

* fix: build error on docker

* Add handle on enter button;

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Gaylord Julien <g.j@mailbox.org>
Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* fix: remove top tech

* fix: update readme

* fix: prevent long container names from overflowing card (#496)

Added min-w-0 to CardTitle to allow text truncation in flexbox.
Without this, flex items have min-width: auto which prevents
the truncate class from working properly.

Fixes #411

* fix: use SFTP readdir for file listing to support non-Linux systems (#495)

The file manager now uses SFTP readdir as the primary method for
listing files, with ls -la as a fallback. This enables compatibility
with MikroTik RouterOS and other non-Linux systems that don't have
standard shell commands.

Fixes #317

* fix: restore SSH connection timeout to 120s for 2FA authentication (#494)

The timeout was reduced from 120s to 30s in v1.10, causing 2FA login
failures. Users with keyboard-interactive authentication (TOTP/2FA)
need sufficient time to enter their verification codes before the
SSH connection times out.

Fixes #404

* feat: add Docker container healthcheck (#493)

* fix: owner should not be marked as shared when host is shared to their role (#492)

* fix: use correct MIME types for image preview (#491)

* fix: prevent session reset when updating host properties (#490)

* fix: add shell creation timeout and improve error handling (#489)

* fix: set default lineHeight to 1.0 for TUI apps compatibility (#488)

* fix: delete all related data when removing user (#487)

* fix: nginx permission denied on restricted kernels (#486)

* fix: skip existing hosts and credentials during JSON import (#485)

Added duplicate detection for SSH hosts (by ip+port+username) and
credentials (by name) during import. Existing items are now skipped
by default, or updated if replaceExisting option is enabled.

This matches the existing behavior of importDismissedAlerts.

Fixes #389

* feat: add firewall status widget for server stats (#484)

* Feature: PWA (#479)

* feat: add PWA support with offline capabilities

- Add web app manifest with icons and theme configuration
- Add service worker with cache-first strategy for static assets
- Add useServiceWorker hook for SW registration
- Add PWA meta tags and Apple-specific tags to index.html
- Update vite.config.ts for optimal asset caching

* Update package-lock.json

* New Crowdin updates (#472)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Afrikaans)

* New translations en.json (Arabic)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Dutch)

* New translations en.json (Norwegian)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Serbian (Cyrillic))

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (English)

* New translations en.json (Vietnamese)

* New translations en.json (German)

* feat: add listening ports widget for server stats (#483)

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

* feat: fix network stats merge and add openapi jsdocs comments

* feat: add workflow/config to auto generate openapi json

* feat: remove locales

* feat: support URL routes to open terminal directly (#156) (#503)

* fix: resolve merge conflict artifacts in dev-1.10.1

- Fix missing closing tags in AppView.tsx NetworkGraphView
- Fix incomplete catch blocks in server-stats.ts and db/index.ts
- Fix missing closing brace in en.json ports section
- Fix HostManagerApp.tsx import path
- Fix stats-widgets.ts type definition
- Fix schema.ts networkTopology table definition
- Add type annotations in user-data-import.ts

* feat: support URL routes to open terminal directly (#156)

- Add /terminal/{hostNameOrId} route for new format
- Keep /hosts/{id}/terminal for backward compatibility
- Smart detection: numeric IDs for ID lookup, otherwise name lookup
- Clean URL after opening to prevent duplicate on refresh
- Show toast error when host not found

---------

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

* feat: add Ctrl+Alt key remapping for browser-blocked shortcuts (#501)

Browsers intercept Ctrl+W/T/N/Q, making them unusable in terminal.
This adds Ctrl+Alt+<key> as an alternative that sends Ctrl+<key>.

- Ctrl+Alt+W → Ctrl+W (nano search, delete word)
- Ctrl+Alt+T → Ctrl+T (transpose chars)
- Ctrl+Alt+N → Ctrl+N (next line)
- Ctrl+Alt+Q → Ctrl+Q (XON flow control)

Fixes Termix-SSH/Support#407

* feat: remove locales

* New Crowdin updates (#504)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Afrikaans)

* New translations en.json (Arabic)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Dutch)

* New translations en.json (Norwegian)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Serbian (Cyrillic))

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (English)

* New translations en.json (Vietnamese)

* New translations en.json (German)

* New translations en.json (Norwegian)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (Greek)

* New translations en.json (Finnish)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Dutch)

* New translations en.json (Norwegian)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Swedish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Finnish)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* feat: add option to disable update checker (#502)

* feat: add option to disable update checker

Add a new setting in User Profile > Settings to disable automatic
update checking on startup and dashboard.

- Adds 'Disable Update Check' toggle in profile settings
- Skips GitHub API calls when disabled (reduces network requests)
- Works for both web app and Electron client

Fixes Termix-SSH/Support#410

* feat: remove locales

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* New Crowdin updates (#505)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Afrikaans)

* New translations en.json (Arabic)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Dutch)

* New translations en.json (Norwegian)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Serbian (Cyrillic))

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (English)

* New translations en.json (Vietnamese)

* New translations en.json (German)

* New translations en.json (Norwegian)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (Greek)

* New translations en.json (Finnish)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Dutch)

* New translations en.json (Norwegian)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Swedish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Finnish)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Bulgarian)

* New translations en.json (Indonesian)

* New translations en.json (Hindi)

* feat: add crowdin i18n

* feat: remove locales

* New Crowdin updates (#506)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Afrikaans)

* New translations en.json (Arabic)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Dutch)

* New translations en.json (Norwegian)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Serbian (Cyrillic))

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (English)

* New translations en.json (Vietnamese)

* New translations en.json (German)

* New translations en.json (Norwegian)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (Greek)

* New translations en.json (Finnish)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Dutch)

* New translations en.json (Norwegian)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Swedish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Finnish)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Bulgarian)

* New translations en.json (Indonesian)

* New translations en.json (Hindi)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Afrikaans)

* New translations en.json (Arabic)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Dutch)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Serbian (Cyrillic))

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Bulgarian)

* New translations en.json (Indonesian)

* New translations en.json (Hindi)

* New translations en.json (Bengali)

* New translations en.json (Thai)

* feat: update readme

* feat: update readme

* feat: update credential editor to use submitting system and add health monitor

* feat: added toggle for command pallete

* feat: added close button on tab dropdown

* feat: added sidebar management and improved some host manager UI/UX

* feat: re-added missing users.ts route from merge

* feat: add toggle for password reset feature in admin settings (#508)

* feat: add sudo support for file manager operations (#509)

* fix: add sudo support for listFiles and improve permission error handling (#512)

* feat: add sudo support for file manager operations

* fix: add sudo support for listFiles and improve permission error handling

---------

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

* feat: fix sudo password dialog ui, add totp/pass reset limiting, and refreshed users screen when auth is outdated

* feat: add copy password button and fixed new line carriage issues and backend crash for auth key

* feat: added quick connection system (ad-hoc)

* Enter Key for Quick Login (#513)

* feat: added -r and -l support for tunnels

* feat: begin dashboard overhaul by splitting into cards and adding customization

* feat: improved full screen apps, overhauled dashboard, updated server stats ui, etc.

* feat: add auth.tsx suppot for fullscreen

* feat: greatly improve network graph ui/ux and migrated to use translations and theme system

* feat: update to use blacksmith

* feat: improve ui for customized tabs and hide add/edit host/credential when submiting

* feat: add warpgate support with a dialog (terminal only)

* feat: expand warpgate to docker/file manager

* fix: docker not working wtih warpgate and none auth failing for terminal

* fix: prevent owner permission loss when sharing host to own role (#514)

* Update Crowdin configuration file

* Update Crowdin configuration file

* Update Linux Portable section with AUR link (#474)

* fix: file manager incorrectly decoding/encoding when editing files (#476)

* fix: electron build errors and skip macos job

* fix: testflight submit failure

* fix: made submit job match build type

* fix: resolve Vite build warnings for mixed static/dynamic imports (#473)

* Update Crowdin configuration file

* Update Crowdin configuration file

* fix: resolve Vite build warnings for mixed static/dynamic imports

- Convert all dynamic imports of main-axios.ts to static imports (10 files)
- Convert all dynamic imports of sonner to static imports (4 files)
- Add manual chunking configuration to vite.config.ts for better bundle splitting
  - react-vendor: React and React DOM
  - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge
  - monaco: Monaco Editor
  - codemirror: CodeMirror and related packages
- Increase chunkSizeWarningLimit to 1000kB

This resolves Vite warnings about mixed import strategies preventing
proper code-splitting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>

* fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent)

---------

Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>

* fix: build error on docker (#477)

* fix: electron build errors and skip macos job

* fix: testflight submit failure

* fix: made submit job match build type

* fix: resolve Vite build warnings for mixed static/dynamic imports (#473)

* Update Crowdin configuration file

* Update Crowdin configuration file

* fix: resolve Vite build warnings for mixed static/dynamic imports

- Convert all dynamic imports of main-axios.ts to static imports (10 files)
- Convert all dynamic imports of sonner to static imports (4 files)
- Add manual chunking configuration to vite.config.ts for better bundle splitting
  - react-vendor: React and React DOM
  - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge
  - monaco: Monaco Editor
  - codemirror: CodeMirror and related packages
- Increase chunkSizeWarningLimit to 1000kB

This resolves Vite warnings about mixed import strategies preventing
proper code-splitting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>

* fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent)

* fix: build error on docker

---------

Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>

* Increase max old space size for npm builds

* Increase Node.js memory limit in Dockerfile

* Remove NODE_OPTIONS from build commands in Dockerfile

* Change runner to blacksmith-4vcpu-ubuntu-2404

* fix: build error on docker

* fix: prevent owner permission loss when sharing host to own role

Fixes #391

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Gaylord Julien <g.j@mailbox.org>
Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* fix: SSH key passphrase not passed for Docker and Tunnel (#521)

* perf: optimize Host Manager for large host lists

- Add ServerStatusContext for shared status polling (reduces API calls from N to 1)
- Move TooltipProvider to component root (eliminates N context instances)
- Add pagination with "Show More" button (limits initial DOM nodes per folder)

Fixes performance issues when managing ~1000 hosts with status monitoring enabled.

* fix: SSH key passphrase not passed to ssh2 for Docker and Tunnel

Database field is `key_password` but code used `keyPassword`.
Added fallback to check both field names.

Affected:
- docker.ts: Docker SSH connections with encrypted keys
- tunnel.ts: Tunnel connections with encrypted keys

---------

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

* perf: optimize Host Manager for large host lists (#520)

- Add ServerStatusContext for shared status polling (reduces API calls from N to 1)
- Move TooltipProvider to component root (eliminates N context instances)
- Add pagination with "Show More" button (limits initial DOM nodes per folder)

Fixes performance issues when managing ~1000 hosts with status monitoring enabled.

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

* fix: add missing mimeTypes definition for image preview (#518)

Fixes Termix-SSH/Support#408

* fix: prevent session reset when updating host properties (#517)

Move WebSocket cleanup logic to a separate unmount-only effect to
prevent SSH sessions from being closed when host properties are updated.

Closes Termix-SSH/Support#401

* fix: backend type error

* feat: make terminal connections more resilient, added connection log, and fixed https/proxy reconnection loop (issue #385)

* feat: improved conneciton log ui/logic

* feat: improved conneciton log ui/logic

* feat: expanded connection log to work across all components (readying for release)

* feat: update readme

* feat: update readme

* fix: build error

* fix: build error

* fix: changed ver

* chore: clean up

* chore: continue clean up

* fix: remove attempts remaining and fix electron errors and some connection log ui inconsistencies

* fix: added missing nginx routes and fixed sudo password copy with sudo password autofil field

* fix: update readme and run cleaner

* fix: update readme

* fix: update readme

* fix: update readme

* feat: update chinese readme

---------

Co-authored-by: Steven Josefs <s.josefs@gmx.de>
Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com>
Co-authored-by: Gaylord Julien <g.j@mailbox.org>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Aditya Tawade <36890395+aditya-tawade@users.noreply.github.com>
This commit is contained in:
Luke Gustafson
2026-01-24 19:49:42 -06:00
committed by GitHub
parent 366667ca92
commit 536dc35258
169 changed files with 79905 additions and 47362 deletions
+242 -16
View File
@@ -548,9 +548,16 @@ async function connectSSHTunnel(
!tunnelConfig.sourceUsername ||
!tunnelConfig.sourceSSHPort
) {
tunnelLogger.error("Invalid tunnel connection details", {
operation: "tunnel_connect",
const missingFields = [];
if (!tunnelConfig) missingFields.push("tunnelConfig");
if (!tunnelConfig?.sourceIP) missingFields.push("sourceIP");
if (!tunnelConfig?.sourceUsername) missingFields.push("sourceUsername");
if (!tunnelConfig?.sourceSSHPort) missingFields.push("sourceSSHPort");
tunnelLogger.error("Invalid tunnel connection details", undefined, {
operation: "tunnel_connect_validation_failed",
tunnelName,
missingFields: missingFields.join(", "),
hasSourceIP: !!tunnelConfig?.sourceIP,
hasSourceUsername: !!tunnelConfig?.sourceUsername,
hasSourceSSHPort: !!tunnelConfig?.sourceSSHPort,
@@ -560,6 +567,7 @@ async function connectSSHTunnel(
status: CONNECTION_STATES.FAILED,
reason: "Missing required connection details",
});
tunnelConnecting.delete(tunnelName);
return;
}
@@ -600,12 +608,19 @@ async function connectSSHTunnel(
};
} else {
const errorMessage = `Cannot connect tunnel '${tunnelName}': shared credentials not available`;
tunnelLogger.error(errorMessage);
tunnelLogger.error(errorMessage, undefined, {
operation: "tunnel_shared_credentials_unavailable",
tunnelName,
requestingUserId: tunnelConfig.requestingUserId,
sourceUserId: tunnelConfig.sourceUserId,
sourceHostId: tunnelConfig.sourceHostId,
});
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: errorMessage,
});
tunnelConnecting.delete(tunnelName);
return;
}
}
@@ -662,12 +677,18 @@ async function connectSSHTunnel(
!resolvedEndpointCredentials.password
) {
const errorMessage = `Cannot connect tunnel '${tunnelName}': endpoint host requires password authentication but no plaintext password available. Enable autostart for endpoint host or configure credentials in tunnel connection.`;
tunnelLogger.error(errorMessage);
tunnelLogger.error(errorMessage, undefined, {
operation: "tunnel_endpoint_password_unavailable",
tunnelName,
endpointHost: `${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`,
endpointAuthMethod: resolvedEndpointCredentials.authMethod,
});
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: errorMessage,
});
tunnelConnecting.delete(tunnelName);
return;
}
@@ -676,12 +697,18 @@ async function connectSSHTunnel(
!resolvedEndpointCredentials.sshKey
) {
const errorMessage = `Cannot connect tunnel '${tunnelName}': endpoint host requires key authentication but no plaintext key available. Enable autostart for endpoint host or configure credentials in tunnel connection.`;
tunnelLogger.error(errorMessage);
tunnelLogger.error(errorMessage, undefined, {
operation: "tunnel_endpoint_key_unavailable",
tunnelName,
endpointHost: `${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`,
endpointAuthMethod: resolvedEndpointCredentials.authMethod,
});
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: errorMessage,
});
tunnelConnecting.delete(tunnelName);
return;
}
@@ -745,6 +772,19 @@ async function connectSSHTunnel(
return;
}
tunnelLogger.error(
`Tunnel connection timeout after 60 seconds for '${tunnelName}'`,
undefined,
{
operation: "tunnel_connection_timeout",
tunnelName,
sourceHost: `${tunnelConfig.sourceUsername}@${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}`,
endpointHost: `${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`,
retryAttempt,
usingSocks5: tunnelConfig.useSocks5 || false,
},
);
try {
conn.end();
} catch (error) {}
@@ -763,7 +803,22 @@ async function connectSSHTunnel(
conn.on("error", (err) => {
clearTimeout(connectionTimeout);
tunnelLogger.error(`SSH error for '${tunnelName}': ${err.message}`);
const errorType = classifyError(err.message);
tunnelLogger.error(`Tunnel connection failed for '${tunnelName}'`, err, {
operation: "tunnel_connect_error",
tunnelName,
errorType,
errorMessage: err.message,
sourceHost: `${tunnelConfig.sourceUsername}@${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}`,
endpointHost: `${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`,
tunnelType: tunnelConfig.tunnelType || "remote",
sourcePort: tunnelConfig.sourcePort,
retryAttempt,
usingSocks5: tunnelConfig.useSocks5 || false,
authMethod: tunnelConfig.sourceAuthMethod,
});
tunnelConnecting.delete(tunnelName);
@@ -771,8 +826,6 @@ async function connectSSHTunnel(
return;
}
const errorType = classifyError(err.message);
if (!manualDisconnects.has(tunnelName)) {
broadcastTunnelStatus(tunnelName, {
connected: false,
@@ -828,28 +881,49 @@ async function connectSSHTunnel(
return;
}
const tunnelType = tunnelConfig.tunnelType || "remote";
const tunnelFlag = tunnelType === "local" ? "-L" : "-R";
const portMapping =
tunnelType === "local"
? `${tunnelConfig.sourcePort}:${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`
: `${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}`;
let tunnelCmd: string;
if (
resolvedEndpointCredentials.authMethod === "key" &&
resolvedEndpointCredentials.sshKey
) {
const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`;
tunnelCmd = `echo '${resolvedEndpointCredentials.sshKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && exec -a "${tunnelMarker}" ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} && rm -f ${keyFilePath}`;
tunnelCmd = `echo '${resolvedEndpointCredentials.sshKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && exec -a "${tunnelMarker}" ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes ${tunnelFlag} ${portMapping} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} && rm -f ${keyFilePath}`;
} else {
tunnelCmd = `exec -a "${tunnelMarker}" sshpass -p '${resolvedEndpointCredentials.password || ""}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}`;
tunnelCmd = `exec -a "${tunnelMarker}" sshpass -p '${resolvedEndpointCredentials.password || ""}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes ${tunnelFlag} ${portMapping} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}`;
}
conn.exec(tunnelCmd, (err, stream) => {
if (err) {
const errorType = classifyError(err.message);
tunnelLogger.error(
`Connection error for '${tunnelName}': ${err.message}`,
`Failed to execute tunnel command for '${tunnelName}'`,
err,
{
operation: "tunnel_exec_error",
tunnelName,
errorType,
errorMessage: err.message,
sourceHost: `${tunnelConfig.sourceUsername}@${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}`,
endpointHost: `${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`,
tunnelType: tunnelConfig.tunnelType || "remote",
sourcePort: tunnelConfig.sourcePort,
endpointPort: tunnelConfig.endpointPort,
retryAttempt,
},
);
conn.end();
activeTunnels.delete(tunnelName);
const errorType = classifyError(err.message);
const shouldNotRetry =
errorType === "AUTHENTICATION_FAILED" ||
errorType === "CONNECTION_FAILED";
@@ -1079,12 +1153,23 @@ async function connectSSHTunnel(
) {
tunnelLogger.error(
`Invalid SSH key format for tunnel '${tunnelName}'. Key should contain both BEGIN and END markers`,
undefined,
{
operation: "tunnel_invalid_ssh_key_format",
tunnelName,
sourceHost: `${tunnelConfig.sourceUsername}@${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}`,
keyType: resolvedSourceCredentials.keyType,
hasBeginMarker:
resolvedSourceCredentials.sshKey.includes("-----BEGIN"),
hasEndMarker: resolvedSourceCredentials.sshKey.includes("-----END"),
},
);
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: "Invalid SSH key format",
});
tunnelConnecting.delete(tunnelName);
return;
}
@@ -1105,12 +1190,20 @@ async function connectSSHTunnel(
} else if (resolvedSourceCredentials.authMethod === "key") {
tunnelLogger.error(
`SSH key authentication requested but no key provided for tunnel '${tunnelName}'`,
undefined,
{
operation: "tunnel_ssh_key_missing",
tunnelName,
sourceHost: `${tunnelConfig.sourceUsername}@${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}`,
authMethod: resolvedSourceCredentials.authMethod,
},
);
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: "SSH key authentication requested but no key provided",
});
tunnelConnecting.delete(tunnelName);
return;
} else {
connOptions.password = resolvedSourceCredentials.password;
@@ -1152,10 +1245,16 @@ async function connectSSHTunnel(
}
} catch (socks5Error) {
tunnelLogger.error("SOCKS5 connection failed for tunnel", socks5Error, {
operation: "socks5_connect",
operation: "tunnel_socks5_connection_failed",
tunnelName,
sourceHost: `${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}`,
proxyHost: tunnelConfig.socks5Host,
proxyPort: tunnelConfig.socks5Port || 1080,
hasProxyAuth: !!(
tunnelConfig.socks5Username && tunnelConfig.socks5Password
),
errorMessage:
socks5Error instanceof Error ? socks5Error.message : "Unknown error",
});
broadcastTunnelStatus(tunnelName, {
connected: false,
@@ -1166,6 +1265,7 @@ async function connectSSHTunnel(
? socks5Error.message
: "Unknown error"),
});
tunnelConnecting.delete(tunnelName);
return;
}
}
@@ -1302,7 +1402,9 @@ async function killRemoteTunnelByMarker(
}
conn.on("ready", () => {
const checkCmd = `ps aux | grep -E '(${tunnelMarker}|ssh.*-R.*${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}|sshpass.*ssh.*-R.*${tunnelConfig.endpointPort})' | grep -v grep`;
const tunnelType = tunnelConfig.tunnelType || "remote";
const tunnelFlag = tunnelType === "local" ? "-L" : "-R";
const checkCmd = `ps aux | grep -E '(${tunnelMarker}|ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}:.*:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}|sshpass.*ssh.*${tunnelFlag})' | grep -v grep`;
conn.exec(checkCmd, (_err, stream) => {
let foundProcesses = false;
@@ -1323,8 +1425,8 @@ async function killRemoteTunnelByMarker(
const killCmds = [
`pkill -TERM -f '${tunnelMarker}'`,
`sleep 1 && pkill -f 'ssh.*-R.*${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}'`,
`sleep 1 && pkill -f 'sshpass.*ssh.*-R.*${tunnelConfig.endpointPort}'`,
`sleep 1 && pkill -f 'ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}:.*:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}'`,
`sleep 1 && pkill -f 'sshpass.*ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}'`,
`sleep 2 && pkill -9 -f '${tunnelMarker}'`,
];
@@ -1450,10 +1552,42 @@ async function killRemoteTunnelByMarker(
}
}
/**
* @openapi
* /ssh/tunnel/status:
* get:
* summary: Get all tunnel statuses
* description: Retrieves the status of all SSH tunnels.
* tags:
* - SSH Tunnels
* responses:
* 200:
* description: A list of all tunnel statuses.
*/
app.get("/ssh/tunnel/status", (req, res) => {
res.json(getAllTunnelStatus());
});
/**
* @openapi
* /ssh/tunnel/status/{tunnelName}:
* get:
* summary: Get tunnel status by name
* description: Retrieves the status of a specific SSH tunnel by its name.
* tags:
* - SSH Tunnels
* parameters:
* - in: path
* name: tunnelName
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Tunnel status.
* 404:
* description: Tunnel not found.
*/
app.get("/ssh/tunnel/status/:tunnelName", (req, res) => {
const { tunnelName } = req.params;
const status = connectionStatus.get(tunnelName);
@@ -1465,6 +1599,39 @@ app.get("/ssh/tunnel/status/:tunnelName", (req, res) => {
res.json({ name: tunnelName, status });
});
/**
* @openapi
* /ssh/tunnel/connect:
* post:
* summary: Connect SSH tunnel
* description: Establishes an SSH tunnel connection with the specified configuration.
* tags:
* - SSH Tunnels
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* sourceHostId:
* type: integer
* tunnelIndex:
* type: integer
* responses:
* 200:
* description: Connection request received.
* 400:
* description: Invalid tunnel configuration.
* 401:
* description: Authentication required.
* 403:
* description: Access denied to this host.
* 500:
* description: Failed to connect tunnel.
*/
app.post(
"/ssh/tunnel/connect",
authenticateJWT,
@@ -1619,6 +1786,35 @@ app.post(
},
);
/**
* @openapi
* /ssh/tunnel/disconnect:
* post:
* summary: Disconnect SSH tunnel
* description: Disconnects an active SSH tunnel.
* tags:
* - SSH Tunnels
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* tunnelName:
* type: string
* responses:
* 200:
* description: Disconnect request received.
* 400:
* description: Tunnel name required.
* 401:
* description: Authentication required.
* 403:
* description: Access denied.
* 500:
* description: Failed to disconnect tunnel.
*/
app.post(
"/ssh/tunnel/disconnect",
authenticateJWT,
@@ -1683,6 +1879,35 @@ app.post(
},
);
/**
* @openapi
* /ssh/tunnel/cancel:
* post:
* summary: Cancel tunnel retry
* description: Cancels the retry mechanism for a failed SSH tunnel connection.
* tags:
* - SSH Tunnels
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* tunnelName:
* type: string
* responses:
* 200:
* description: Cancel request received.
* 400:
* description: Tunnel name required.
* 401:
* description: Authentication required.
* 403:
* description: Access denied.
* 500:
* description: Failed to cancel tunnel retry.
*/
app.post(
"/ssh/tunnel/cancel",
authenticateJWT,
@@ -1806,6 +2031,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
tunnelConnection.endpointHost,
tunnelConnection.endpointPort,
),
tunnelType: tunnelConnection.tunnelType || "remote",
sourceHostId: host.id,
tunnelIndex: tunnelIndex,
hostName: host.name || `${host.username}@${host.ip}`,