Compare commits

..

149 Commits

Author SHA1 Message Date
Luke Gustafson b448ae48e0 New Crowdin updates (#827)
* New translations en.json (Romanian)

[ci skip]

* New translations en.json (French)

[ci skip]

* New translations en.json (Spanish)

[ci skip]

* New translations en.json (Afrikaans)

[ci skip]

* New translations en.json (Arabic)

[ci skip]

* New translations en.json (Bulgarian)

[ci skip]

* New translations en.json (Catalan)

[ci skip]

* New translations en.json (Czech)

[ci skip]

* New translations en.json (Danish)

[ci skip]

* New translations en.json (German)

[ci skip]

* New translations en.json (Greek)

[ci skip]

* New translations en.json (Finnish)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Hungarian)

[ci skip]

* New translations en.json (Italian)

[ci skip]

* New translations en.json (Japanese)

[ci skip]

* New translations en.json (Korean)

[ci skip]

* New translations en.json (Dutch)

[ci skip]

* New translations en.json (Norwegian)

[ci skip]

* New translations en.json (Polish)

[ci skip]

* New translations en.json (Portuguese)

[ci skip]

* New translations en.json (Russian)

[ci skip]

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

[ci skip]

* New translations en.json (Swedish)

[ci skip]

* New translations en.json (Turkish)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Chinese Simplified)

[ci skip]

* New translations en.json (Chinese Traditional)

[ci skip]

* New translations en.json (Vietnamese)

[ci skip]

* New translations en.json (Portuguese, Brazilian)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Bengali)

[ci skip]

* New translations en.json (Thai)

[ci skip]

* New translations en.json (Hindi)

[ci skip]
2026-05-27 15:45:26 -05:00
LukeGus 48b6e4a595 chore: update readme images 2026-05-27 15:05:25 -05:00
LukeGus 84e3989511 chore: update crowdin to match new en.json path 2026-05-27 14:30:18 -05:00
Luke Gustafson 89c7bc0f76 v2.3.0
v2.3.0
2026-05-27 15:23:46 -04:00
LukeGus 41d30bb585 chore: merge main into dev-2.3.0 to resolve PR conflicts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:23:20 -05:00
LukeGus ff399ec0bc feat: update c2s tunnel to match new UI 2026-05-27 14:10:19 -05:00
LukeGus abb1f60b71 fix: show electron change server header for localserver 2026-05-27 13:54:13 -05:00
LukeGus f81587888f fix: nan patch for electron build error 2026-05-27 13:20:11 -05:00
LukeGus fa29997bf4 fix: sqlite patch so electron builds 2026-05-27 12:40:49 -05:00
Luke Gustafson d14615659a fix: patch better-sqlite3 for Electron 42 rebuild (#819) 2026-05-27 01:22:50 -05:00
LukeGus 7e94235a28 chore: format 2026-05-27 01:07:06 -05:00
LukeGus a418a4df0e fix: make telnet icon different from terminal 2026-05-27 00:29:28 -05:00
LukeGus 2ea9a481a3 fix: oklch invalid wanring 2026-05-27 00:24:12 -05:00
Luke Gustafson a43efb11a3 Update README.md 2026-05-27 00:01:18 -05:00
LukeGus 4515111d7f chore: update readme's 2026-05-26 23:59:57 -05:00
LukeGus 8b209bc3c6 feat: add snippets features 2026-05-26 23:10:17 -05:00
LukeGus d01b1a03cb feat: add snippets features 2026-05-26 23:02:19 -05:00
LukeGus 0a32bf518e chore: releaes prep 2026-05-26 22:58:00 -05:00
LukeGus c5b9467186 chore: releaes prep 2026-05-26 22:36:45 -05:00
LukeGus 9c12a84a91 feat: continued general UI improvements 2026-05-26 22:05:58 -05:00
LukeGus 0fe3f8d126 feat: sidebar saves state across mobile swtich, guacd hosts not migraitng passwords, terminal wrong size 2026-05-26 17:48:19 -05:00
LukeGus 5c2c9c179e feat: seperated out sharing, improved host/credential loading, reduced host manager bloat 2026-05-26 16:57:35 -05:00
LukeGus 4df71e77cb feat: add CA-signed certificate authentication for SSH credentials (#812) 2026-05-26 15:44:37 -05:00
Marc Reinke 698a5648ec feat: add CA-signed certificate authentication for SSH credentials
Support OpenSSH certificate-based authentication (-cert.pub files) in
the Credentials manager. When a CA-signed certificate is stored alongside
a private key, Termix uses it during SSH connection establishment so that
servers relying on certificate-based authorization work out of the box.

Changes:
- db/schema.ts: add cert_public_key column to ssh_credentials table
- db/index.ts: auto-migration via addColumnIfNotExists
- routes/credentials.ts: expose certPublicKey in create/update/get endpoints
- ssh/auth-manager.ts: include certPublicKey in ResolvedCredentials
- ssh/host-resolver.ts: propagate certPublicKey when resolving credentials
- ssh/opkssh-cert-auth.ts: refactor shared logic into _applyCertToConnection;
  export new setupCACertAuth() with optional passphrase support
- ssh/terminal.ts: call setupCACertAuth() when a certificate is present
- utils/ssh-key-utils.ts: detect all OpenSSH cert types in public key parser
- types/index.ts: add certPublicKey to Credential, CredentialBackend,
  CredentialData interfaces
- CredentialAuthenticationTab.tsx: new CA Certificate section with file
  upload, paste editor and automatic cert-type badge
- CredentialEditor.tsx: certPublicKey wired into form schema and submit
- CredentialViewer.tsx: show certificate status in security tab
- locales/en.json: add i18n strings for new UI elements
2026-05-26 15:44:08 -05:00
Luke Gustafson d3a2992898 Update sha256 checksum for Termix cask 2026-05-26 15:42:18 -05:00
LukeGus 57d9e0c035 fix: jump hosts failing 2026-05-26 15:30:22 -05:00
LukeGus 0a3517f9b0 fix: jump hosts failing 2026-05-26 15:28:44 -05:00
LukeGus 3011de6abb fix: guacd hosts not migarting password 2026-05-22 17:16:39 -05:00
LukeGus 6b9784c8fc feat: improve sentinel system 2026-05-22 16:56:53 -05:00
LukeGus ee4520a1e0 fix: guacd connec tions migrating incorrectly from allowing all protocls in one host migration 2026-05-22 16:36:54 -05:00
LukeGus 4c2a317f24 fix: electron backend not starting 2026-05-22 16:16:14 -05:00
LukeGus 3bb305bc38 fix: websocket auth error electron 2026-05-22 15:55:03 -05:00
LukeGus b82111c273 fix: overalpping guacd ui 2026-05-22 00:14:33 -05:00
LukeGus a9f82a5a45 feat: readd mange mode for host manager, guacd toolbar improvements, connection issues, etc. 2026-05-22 00:02:50 -05:00
LukeGus 6bcbadcc25 feat: guacd improvements, created tool bar 2026-05-21 22:35:58 -05:00
LukeGus 23a0160ef0 fix: auth failed electron 2026-05-21 14:22:25 -05:00
LukeGus 170080399b feat: seperated host manager into 2 rail tabs, electron fixes for auth, shared credential fixes 2026-05-20 22:55:16 -05:00
LukeGus 15e815a632 chore: continued redesign readme 2026-05-20 17:11:37 -05:00
LukeGus 162a512729 chore: continued redesign readme 2026-05-20 17:10:28 -05:00
LukeGus 22ff064c96 chore: continued redesign readme 2026-05-20 17:06:16 -05:00
LukeGus df6fe0430b chore: continued redesign readme 2026-05-20 17:03:43 -05:00
LukeGus 5bb1cfc03c chore: continued redesign readme 2026-05-20 17:01:31 -05:00
LukeGus 4710b6507a chore: continued redesign readme 2026-05-20 16:58:50 -05:00
LukeGus aa71183d08 chore: continued redesign readme 2026-05-20 16:56:02 -05:00
LukeGus a045d660fc chore: continued redesign readme 2026-05-20 16:53:10 -05:00
LukeGus b91c8c3dd4 chore: continued redesign readme 2026-05-20 16:48:42 -05:00
LukeGus 2c5f7d1e66 chore: continued redesign readme 2026-05-20 16:45:12 -05:00
LukeGus 5b890e9538 chore: continued redesign readme 2026-05-20 16:44:27 -05:00
LukeGus 0bb127737c chore: remove unneeded npm packages and redesign readme 2026-05-20 16:38:29 -05:00
LukeGus fb882b9ce5 fix: build failure 2026-05-20 15:17:02 -05:00
LukeGus cf27e02b23 fix: build failure 2026-05-20 15:12:58 -05:00
LukeGus 7192a0fc82 fix: build failure 2026-05-20 15:04:37 -05:00
LukeGus d04ecc74ed fix: build failure 2026-05-20 15:01:09 -05:00
LukeGus ebeb9ede17 fix: build failure 2026-05-20 14:58:35 -05:00
LukeGus bc713aca7d feat: terminal theme improvemnts, file manager improvements, host manager improvements (ready for initial user testing?) 2026-05-20 14:55:41 -05:00
LukeGus 9f0aaa16a5 fix: no db entry for connection protocols 2026-05-20 14:02:26 -05:00
LukeGus dca2222689 Merge remote-tracking branch 'origin/dev-2.3.0' into dev-2.3.0 2026-05-20 13:58:28 -05:00
LukeGus ec69c338da feat: host manager improvements and added over electron files 2026-05-20 13:58:15 -05:00
TomyJan 3375733789 fix: resolve backend build errors, preserve sudo file read buffers, and format code (#798)
* fix: resolve backend TypeScript build errors and preserve sudo file read buffers

* style: format code

* fix: ssh2 ESM import failure

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:18:42 -05:00
ZacharyZcR 21f24a8386 feat: add OIDC auto-provision setting independent of registration toggle (#795)
Allows OIDC-authenticated users to be auto-provisioned even when
"Allow new account registration" is disabled. Adds an admin UI toggle
and corresponding API endpoints (GET/PATCH /users/oidc-auto-provision).
Falls back to OIDC_ALLOW_REGISTRATION env var if the setting is unset.
2026-05-20 13:16:05 -05:00
ZacharyZcR ecb7f4f2c5 fix: add 3-way horizontal split variant and fix file manager overflow in split view (#796)
Adds a "3-Way (H)" layout option (2 top + 1 bottom) alongside the
existing "3-Way (V)" (1 left + 2 right).

Fixes file manager content overflowing its split pane by adding
min-h-0 to nested flex containers that were missing it.
2026-05-20 13:15:59 -05:00
TomyJan 5971bcfe88 feat(auth): add silent OIDC sign-in (#799) 2026-05-20 13:15:08 -05:00
LukeGus d3fa8c4420 feat: host manager bug fixes, i18n improvements, general tab fixes 2026-05-20 13:13:56 -05:00
ZacharyZcR a966cc930b fix: load Caskaydia Cove Nerd Font via @font-face declarations (#794)
The default terminal font was configured but never loaded via CSS,
causing browsers to fall back to generic monospace. This broke column
alignment and TUI rendering (ncurses menus, box-drawing chars).
2026-05-18 14:51:56 +08:00
ZacharyZcR 63c8d601b5 feat: add confirmation prompt before closing active session tabs (#793)
Shows a toast confirmation when closing terminal, RDP, VNC or telnet
tabs to prevent accidental session loss from misclicks.
2026-05-18 05:53:15 +08:00
ZacharyZcR 3d42642e94 fix: flush in-memory database to disk on graceful shutdown (#792)
SIGTERM/SIGINT handlers previously called process.exit(0) immediately
without persisting the in-memory SQLite database. This caused session
data loss on container restart, forcing users to re-authenticate.
2026-05-18 05:30:13 +08:00
ZacharyZcR 65d67e6f79 fix: host connections failing with "Authentication failed" (#791)
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
2026-05-18 05:17:10 +08:00
ZacharyZcR a354a45b1c Merge pull request #765 from ZacharyZcR/feat/ctrl-alt-delete-button
feat: add ctrl+alt+delete button for rdp/vnc sessions
2026-05-18 04:47:35 +08:00
ZacharyZcR c488f92e00 Merge pull request #767 from ZacharyZcR/feat/clone-host
feat: implement clone host functionality
2026-05-18 04:47:30 +08:00
ZacharyZcR 125ba1e82c Merge pull request #770 from ZacharyZcR/fix/default-session-persistence
fix: enable terminal session persistence by default
2026-05-18 04:47:26 +08:00
ZacharyZcR d74e1b141a Merge pull request #781 from ZacharyZcR/feat/guacd-status-check
feat: check guacd availability before RDP/VNC connection
2026-05-18 04:47:22 +08:00
ZacharyZcR 629961f70b Merge pull request #789 from ZacharyZcR/feat/tmux-experience-improvements
feat: improve tmux session management and UX
2026-05-18 04:47:18 +08:00
ZacharyZcR fc9860c4d0 feat: add ctrl+alt+delete button for rdp/vnc sessions 2026-05-18 04:43:53 +08:00
ZacharyZcR 6a98e3db7a feat: implement clone host functionality 2026-05-18 04:41:49 +08:00
ZacharyZcR dd0bfb5190 fix: enable terminal session persistence by default 2026-05-18 04:40:27 +08:00
ZacharyZcR a1a442281b feat: check guacd availability before RDP/VNC connection 2026-05-18 04:39:46 +08:00
ZacharyZcR 47d660a509 feat: improve tmux session management and UX
Replace hardcoded setTimeout timing with exec channel polling for
reliable tmux session readiness detection. Add meaningful session
naming (termix-<hostId>-<rand>), aggressive-resize for multi-client
support, and a detach button in the terminal UI.
2026-05-18 04:38:19 +08:00
ZacharyZcR ebed4a6d2e Merge pull request #774 from ZacharyZcR/fix/proxmox-bare-metal-deploy
feat: add bare-metal deployment configs for Proxmox/LXC
2026-05-18 04:36:45 +08:00
ZacharyZcR e156052cc7 Merge pull request #760 from ZacharyZcR/fix/folder-drag-upload
fix: support folder drag-and-drop upload in file manager
2026-05-18 04:36:41 +08:00
ZacharyZcR 8e725bd181 Merge pull request #759 from ZacharyZcR/fix/jumphost-shared-access
fix: resolve jump hosts for shared access users
2026-05-18 04:36:37 +08:00
ZacharyZcR 396a672a1c Merge pull request #776 from ZacharyZcR/feat/per-user-shared-host-credentials
feat: support per-user credentials on shared hosts
2026-05-18 04:36:33 +08:00
ZacharyZcR cfb9e4bcaa Merge pull request #780 from ZacharyZcR/feat/rdp-over-ssh-tunnel
feat: support RDP/VNC over SSH tunnel via jump hosts
2026-05-18 04:36:29 +08:00
ZacharyZcR c9886b1a76 Merge pull request #772 from ZacharyZcR/fix/large-file-download-streaming
fix: use streaming download for large files in file manager
2026-05-18 04:36:25 +08:00
ZacharyZcR c35555056e Merge pull request #787 from ZacharyZcR/fix/editor-sudo-fallback
fix: add sudo fallback for file read/write in editor
2026-05-18 04:36:10 +08:00
ZacharyZcR db657444cb Merge pull request #761 from ZacharyZcR/fix/ssh-agent-forwarding
fix: implement ssh agent forwarding with in-memory agent
2026-05-18 04:36:02 +08:00
ZacharyZcR 5b1d84656b Merge pull request #788 from ZacharyZcR/feat/system-browser-oidc-auth
feat: implement RFC 8252 system browser OIDC authentication for desktop
2026-05-18 04:35:55 +08:00
ZacharyZcR 3d5296cdaa Merge pull request #763 from ZacharyZcR/feat/pinned-hosts-quick-actions
feat: show pinned hosts in quick actions card
2026-05-18 04:35:52 +08:00
ZacharyZcR d79fb809de Merge pull request #786 from ZacharyZcR/feat/cloudflare-tunnel-ssh
feat: support SSH connections through Cloudflare Tunnels
2026-05-18 04:35:47 +08:00
ZacharyZcR 58fa7b8211 Merge pull request #784 from ZacharyZcR/fix/guacamole-protocol-version
fix: patch guacamole-lite to support guacd 1.6.0 protocol version
2026-05-18 04:35:43 +08:00
ZacharyZcR 0933cd56c1 Merge pull request #764 from ZacharyZcR/feat/opkssh-host-sharing
feat: allow sharing opkssh-authenticated hosts
2026-05-18 04:35:39 +08:00
ZacharyZcR 8b2d44e151 Merge pull request #783 from ZacharyZcR/fix/chacha20-cipher-support
fix: properly detect chacha20-poly1305 support before advertising it
2026-05-18 04:35:24 +08:00
ZacharyZcR a1123cb51c Merge pull request #782 from ZacharyZcR/feat/oidc-admin-group
feat: sync admin status from OIDC group membership on login
2026-05-18 04:35:20 +08:00
ZacharyZcR 982cfab978 Merge pull request #775 from ZacharyZcR/fix/guacamole-drive-redirection
fix: implement guacamole drive redirection file transfer
2026-05-18 04:35:16 +08:00
ZacharyZcR d27d8ad149 Merge pull request #769 from ZacharyZcR/fix/electron-url-handler
fix: validate terminal link URLs before opening in external browser
2026-05-18 04:35:11 +08:00
ZacharyZcR 93716c9d64 Merge pull request #766 from ZacharyZcR/feat/runtime-base-path
feat: support runtime base path configuration via BASE_PATH env var
2026-05-18 04:35:07 +08:00
ZacharyZcR c833b47a4a Merge pull request #778 from ZacharyZcR/feat/snippet-host-filter
feat: add host filter for snippets
2026-05-18 04:35:03 +08:00
ZacharyZcR e0db78ed6d Merge pull request #757 from ZacharyZcR/fix/oidc-callback-secret-decoding
fix: decode oidc client_secret before token exchange
2026-05-18 04:34:59 +08:00
ZacharyZcR bf840bef2a Merge pull request #762 from ZacharyZcR/fix/rdp-ignore-cert-default
fix: default ignore-cert to true for rdp connections
2026-05-18 04:34:44 +08:00
ZacharyZcR 5f93ffe4b3 Merge pull request #771 from ZacharyZcR/fix/file-manager-auto-sudo
fix: auto-set sudo password in file manager from host config
2026-05-18 04:34:40 +08:00
ZacharyZcR 7796014559 Merge pull request #768 from ZacharyZcR/fix/linux-electron-fork-cwd
fix: resolve linux electron backend fork ENOTDIR error
2026-05-18 04:34:36 +08:00
ZacharyZcR c5bc870481 Merge pull request #785 from ZacharyZcR/fix/docker-guacd-arm64
fix: improve docker-compose guacd config for ARM64 and network isolation
2026-05-18 04:34:31 +08:00
ZacharyZcR cfd4b08396 Merge pull request #779 from ZacharyZcR/fix/database-import-order
fix: disable foreign key constraints during database import
2026-05-18 04:34:27 +08:00
ZacharyZcR 748b01de1f Merge pull request #777 from ZacharyZcR/fix/vnc-tls-support
fix: enable VNC TLS/VeNCrypt support with security mode negotiation
2026-05-18 04:34:22 +08:00
ZacharyZcR 608852cd04 Merge pull request #773 from ZacharyZcR/fix/macos-electron-esm-error
fix: override package.json type to commonjs for electron builds
2026-05-18 04:34:11 +08:00
LukeGus 99d4ecad12 feat: continued improvements 2026-05-14 00:55:32 -05:00
LukeGus c26288e024 feat: continued improvements 2026-05-14 00:55:12 -05:00
LukeGus 2a45cfea9a feat: update readme 2026-05-13 23:51:00 -05:00
LukeGus ab2b35b733 feat: update readme 2026-05-13 23:50:07 -05:00
LukeGus ae7eba8936 feat: update readme 2026-05-13 23:48:45 -05:00
LukeGus d295c0b15e feat: update readme 2026-05-13 23:46:34 -05:00
LukeGus 165ef058d9 feat: update readme 2026-05-13 23:44:02 -05:00
Luke Gustafson b30ed8eabe Update sha256 checksum for Termix cask 2026-05-13 19:06:44 -05:00
LukeGus 5bf59f87c0 feat: improve all connection types 2026-05-13 17:44:13 -05:00
ZacharyZcR 4f23e3d22c feat: implement RFC 8252 system browser OIDC authentication for desktop 2026-05-13 19:18:44 +08:00
ZacharyZcR 88e73bd69c fix: add sudo fallback for file read/write in editor 2026-05-13 19:10:12 +08:00
ZacharyZcR 0619a5a038 feat: support SSH connections through Cloudflare Tunnels 2026-05-13 19:05:47 +08:00
ZacharyZcR a0162bccdd fix: improve docker-compose guacd config for ARM64 and network isolation 2026-05-13 18:52:47 +08:00
ZacharyZcR fe86e48785 fix: patch guacamole-lite to support protocol VERSION_1_5_0 2026-05-13 18:38:13 +08:00
ZacharyZcR 40d8f56c23 fix: properly detect chacha20-poly1305 support before advertising it 2026-05-13 18:29:39 +08:00
ZacharyZcR e4441f5929 feat: sync admin status from OIDC group membership on login 2026-05-13 18:25:15 +08:00
ZacharyZcR 939bc97181 feat: support RDP/VNC over SSH tunnel via jump hosts 2026-05-13 18:12:43 +08:00
ZacharyZcR 1ca299e141 fix: disable foreign key constraints during database import 2026-05-13 18:02:48 +08:00
ZacharyZcR 4055d076b4 feat: add host filter for snippets 2026-05-13 17:51:05 +08:00
ZacharyZcR c481a5c702 fix: enable VNC TLS/VeNCrypt support with security mode negotiation 2026-05-13 17:34:30 +08:00
ZacharyZcR 5c9d8bfa34 feat: support per-user credentials on shared hosts 2026-05-13 17:26:24 +08:00
ZacharyZcR 453b6ea138 fix: implement guacamole drive redirection file transfer 2026-05-13 17:17:16 +08:00
ZacharyZcR 106b556ce7 feat: add bare-metal deployment configs for Proxmox/LXC
Provide ready-to-use nginx, systemd, and environment configs
for bare-metal installs. Community Proxmox scripts currently
sed-hack the Docker nginx.conf with patterns that don't match,
causing broken pid/log paths and tmpfiles issues on reboot.
2026-05-13 17:12:04 +08:00
ZacharyZcR 1eacb52dab fix: override package.json type to commonjs for electron builds 2026-05-13 16:57:06 +08:00
ZacharyZcR fafc1437cd fix: use streaming download for large files in file manager 2026-05-13 16:52:56 +08:00
ZacharyZcR 9852b1f987 fix: auto-set sudo password in file manager from host config 2026-05-13 16:46:59 +08:00
ZacharyZcR dcc75c980b fix: validate terminal link URLs before opening in external browser 2026-05-13 16:30:41 +08:00
ZacharyZcR 69fbce9594 fix: resolve linux electron backend fork ENOTDIR error 2026-05-13 16:27:31 +08:00
ZacharyZcR cf27af00ac fix: sanitize dpi value before passing to guacd 2026-05-13 16:25:18 +08:00
ZacharyZcR 1727601fbe feat: support runtime base path configuration via BASE_PATH env var 2026-05-13 16:13:20 +08:00
ZacharyZcR 634a84e93d feat: allow sharing opkssh-authenticated hosts 2026-05-13 16:02:24 +08:00
ZacharyZcR 9f316922c3 feat: show pinned hosts in quick actions card 2026-05-13 15:56:49 +08:00
ZacharyZcR 066a503a7a fix: default ignore-cert to true for rdp connections 2026-05-13 15:44:06 +08:00
ZacharyZcR 28734abc6e fix: implement ssh agent forwarding with in-memory agent 2026-05-13 15:36:14 +08:00
ZacharyZcR 77c1a1b518 fix: support folder drag-and-drop upload in file manager 2026-05-13 15:25:21 +08:00
ZacharyZcR 53d4e256ae fix: resolve jump hosts for shared access users 2026-05-13 15:19:18 +08:00
LukeGus be43718260 fix: host connections failing with "Authentication failed" 2026-05-13 02:01:47 -05:00
ZacharyZcR 51d469008c fix: decode oidc client_secret before token exchange 2026-05-13 14:56:45 +08:00
LukeGus 84a8dfa050 fix: remove git commit head line 2026-05-13 01:36:35 -05:00
LukeGus ec87f8a4d1 feat: improve admin settings and user profile 2026-05-13 01:31:11 -05:00
LukeGus 1be5d20744 feat: improve network graph 2026-05-13 01:31:10 -05:00
LukeGus 6212a24567 feat: improve dashboard and commpand pallete 2026-05-13 01:31:10 -05:00
LukeGus 835b98a102 feat: continued migration 2026-05-13 01:30:24 -05:00
LukeGus 0efcb7de7e fix: eslint error and format 2026-05-13 01:29:44 -05:00
LukeGus 93fa749d09 feat: continue migrating content for ui redesign 2026-05-13 01:29:43 -05:00
820 changed files with 64301 additions and 208974 deletions
+1 -1
View File
@@ -5,4 +5,4 @@ contact_links:
about: Report any feature requests or bugs in the support center
- name: Discord
url: https://discord.gg/jVQGdvHDrf
about: Official Termix Discord server for general discussion (not recommended for support)
about: Official Termix Discord server for general discussion and quick support
+4 -47
View File
@@ -1,19 +1,9 @@
version: 2
updates:
# npm dependencies (single root package.json, no workspaces)
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 15
labels:
- "dependencies"
- "npm"
commit-message:
prefix: "chore"
prefix-development: "chore"
include: "scope"
interval: "daily"
groups:
dev-patch-updates:
dependency-type: "development"
@@ -31,53 +21,20 @@ updates:
dependency-type: "production"
update-types:
- "minor"
# Major bumps grouped so they land as a single reviewable PR instead of
# one-per-package noise. These often need manual follow-up (Electron,
# React, Vite, Tailwind, Express 5, etc.).
major-updates:
update-types:
- "major"
# Docker base images (docker/Dockerfile + docker-compose / compose-dev)
- package-ecosystem: "docker"
directory: "/docker"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 10
labels:
- "dependencies"
- "docker"
commit-message:
prefix: "chore"
include: "scope"
interval: "daily"
groups:
docker-patch-updates:
patch-updates:
update-types:
- "patch"
docker-minor-updates:
minor-updates:
update-types:
- "minor"
docker-major-updates:
update-types:
- "major"
# GitHub Actions used across the workflows in .github/workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 10
labels:
- "dependencies"
- "github-actions"
commit-message:
prefix: "ci"
include: "scope"
groups:
github-actions:
update-types:
- "patch"
- "minor"
- "major"
-1
View File
@@ -1 +0,0 @@
custom: https://donate.termix.site/
-157
View File
@@ -1,157 +0,0 @@
name: Retarget and Merge Dependabot PRs
on:
schedule:
- cron: "0 6 * * *"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
retarget-and-merge:
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 1
- name: Resolve newest dev branch
id: dev
env:
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
run: |
REFS=$(gh api "repos/${{ github.repository }}/branches" --paginate -q '.[].name')
# The helper exits non-zero when no dev-X.Y.Z branch exists; treat that
# as "nothing to do" rather than a workflow failure.
if DEV_BRANCH=$(printf '%s\n' "$REFS" | node scripts/latest-dev-branch.cjs 2>/dev/null); then
echo "Newest dev branch: $DEV_BRANCH"
echo "branch=$DEV_BRANCH" >> "$GITHUB_OUTPUT"
else
echo "No dev-X.Y.Z branch open; nothing to retarget."
echo "branch=" >> "$GITHUB_OUTPUT"
fi
- name: Retarget and merge Dependabot PRs
if: ${{ steps.dev.outputs.branch != '' }}
env:
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
DEV_BRANCH: ${{ steps.dev.outputs.branch }}
REPO: ${{ github.repository }}
run: |
set -uo pipefail
CONFLICT_LABEL="dependabot-rebase-requested"
# Ensure the bookkeeping label exists (no-op if it already does).
gh label create "$CONFLICT_LABEL" --repo "$REPO" \
--color "D93F0B" --description "Retarget workflow asked Dependabot to rebase a conflicting PR" \
2>/dev/null || true
# True if the PR already carries the conflict label.
has_conflict_label() {
gh pr view "$1" --repo "$REPO" --json labels \
-q '.labels[].name' | grep -qx "$CONFLICT_LABEL"
}
# Wait until GitHub has a definite mergeable verdict for a PR (it
# returns UNKNOWN while recomputing after a base change or a push).
# Echoes "<mergeable> <mergeStateStatus>".
wait_for_verdict() {
local pr="$1" mergeable state
for _ in $(seq 1 30); do
read -r mergeable state < <(gh pr view "$pr" --repo "$REPO" \
--json mergeable,mergeStateStatus \
-q '.mergeable + " " + .mergeStateStatus')
if [ "$mergeable" != "UNKNOWN" ] && [ "$state" != "UNKNOWN" ]; then
echo "$mergeable $state"
return 0
fi
sleep 20
done
echo "$mergeable $state"
}
# Phase 1: retarget every open Dependabot PR from main onto the dev
# branch. This kicks off a Dependabot rebase for each.
PR_NUMBERS=$(gh pr list --repo "$REPO" \
--author "app/dependabot" \
--base main \
--state open \
--json number -q '.[].number')
# Pick up PRs already sitting on the dev branch from a previous run too.
PR_NUMBERS="$PR_NUMBERS $(gh pr list --repo "$REPO" \
--author "app/dependabot" \
--base "$DEV_BRANCH" \
--state open \
--json number -q '.[].number')"
PR_NUMBERS=$(printf '%s\n' $PR_NUMBERS | sort -un)
if [ -z "$PR_NUMBERS" ]; then
echo "No open Dependabot PRs to process."
exit 0
fi
for PR in $PR_NUMBERS; do
BASE=$(gh pr view "$PR" --repo "$REPO" --json baseRefName -q .baseRefName)
if [ "$BASE" != "$DEV_BRANCH" ]; then
echo "Retargeting PR #$PR ($BASE -> $DEV_BRANCH)"
gh pr edit "$PR" --repo "$REPO" --base "$DEV_BRANCH"
fi
done
# Phase 2: merge one at a time. Each merge can make the remaining npm
# PRs stale, so re-check immediately before merging and rebase stragglers.
for PR in $PR_NUMBERS; do
echo "::group::PR #$PR"
read -r MERGEABLE STATE < <(wait_for_verdict "$PR")
echo " mergeable=$MERGEABLE mergeStateStatus=$STATE"
# BEHIND = clean but needs the latest base; ask Dependabot to rebase
# and skip for now (next run merges it once it is up to date).
if [ "$STATE" = "BEHIND" ]; then
echo " PR #$PR is behind $DEV_BRANCH; asking Dependabot to rebase."
gh pr comment "$PR" --repo "$REPO" --body "@dependabot rebase"
echo "::endgroup::"
continue
fi
# DIRTY / CONFLICTING = a real conflict. Try a rebase once (label it so
# we can tell next time); if it is STILL conflicting on a later run
# despite already being labelled, the rebase failed for good - close it
# so Dependabot reopens a fresh PR against the current dev branch.
if [ "$MERGEABLE" = "CONFLICTING" ] || [ "$STATE" = "DIRTY" ]; then
if has_conflict_label "$PR"; then
echo " PR #$PR still conflicts after a prior rebase request; closing so Dependabot reopens it fresh."
gh pr close "$PR" --repo "$REPO" --delete-branch \
--comment "Closing: this PR still conflicts with $DEV_BRANCH after a rebase attempt (its changes are likely already merged). Dependabot will reopen a fresh PR computed against the current $DEV_BRANCH."
else
echo " PR #$PR conflicts with $DEV_BRANCH; requesting a rebase and labelling it."
gh pr edit "$PR" --repo "$REPO" --add-label "$CONFLICT_LABEL"
gh pr comment "$PR" --repo "$REPO" --body "@dependabot rebase"
fi
echo "::endgroup::"
continue
fi
# A clean PR that was previously flagged has recovered - drop the label.
if has_conflict_label "$PR"; then
gh pr edit "$PR" --repo "$REPO" --remove-label "$CONFLICT_LABEL" || true
fi
echo " Squash-merging PR #$PR"
if gh pr merge "$PR" --repo "$REPO" --squash --admin; then
echo " Merged PR #$PR"
# Give GitHub a moment to mark the now-stale siblings BEHIND.
sleep 15
else
echo " Could not merge PR #$PR now; it will be retried next run."
fi
echo "::endgroup::"
done
+11 -41
View File
@@ -14,63 +14,35 @@ on:
options:
- Development
- Production
source_ref:
description: "Git ref/SHA to build (defaults to the workflow ref)"
required: false
default: ""
workflow_call:
inputs:
version:
description: "Version to build (e.g., 1.8.0)"
required: true
type: string
build_type:
description: "Build type (Development or Production)"
required: true
type: string
dry_run:
description: "Build the image but do not push to any registry"
required: false
type: boolean
default: false
source_ref:
description: "Git ref/SHA to build"
required: false
type: string
default: ""
jobs:
build:
runs-on: blacksmith-8vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v5
with:
ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Resolve source revision
run: echo "SOURCE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
platforms: linux/amd64,linux/arm64
- name: Setup Docker Buildx
uses: useblacksmith/setup-docker-builder@v1
uses: docker/setup-buildx-action@v4
- name: Determine tags
id: tags
run: |
VERSION=${{ inputs.version }}
BUILD_TYPE=${{ inputs.build_type }}
VERSION=${{ github.event.inputs.version }}
BUILD_TYPE=${{ github.event.inputs.build_type }}
TAGS=()
ALL_TAGS=()
if [ "$BUILD_TYPE" = "Production" ]; then
TAGS+=("release-$VERSION" "$VERSION" "latest")
TAGS+=("release-$VERSION" "latest")
for tag in "${TAGS[@]}"; do
ALL_TAGS+=("ghcr.io/lukegus/termix:$tag")
ALL_TAGS+=("docker.io/bugattiguy527/termix:$tag")
@@ -85,7 +57,6 @@ jobs:
echo "ALL_TAGS=$(IFS=,; echo "${ALL_TAGS[*]}")" >> $GITHUB_ENV
- name: Login to GHCR
if: ${{ !inputs.dry_run }}
uses: docker/login-action@v4
with:
registry: ghcr.io
@@ -93,29 +64,28 @@ jobs:
password: ${{ secrets.GHCR_TOKEN }}
- name: Login to Docker Hub (prod only)
if: ${{ inputs.build_type == 'Production' && !inputs.dry_run }}
if: ${{ github.event.inputs.build_type == 'Production' }}
uses: docker/login-action@v4
with:
username: bugattiguy527
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push multi-arch image
uses: useblacksmith/build-push-action@v2
uses: docker/build-push-action@v7
with:
context: .
file: ./docker/Dockerfile
push: ${{ !inputs.dry_run }}
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ env.ALL_TAGS }}
build-args: |
BUILDKIT_INLINE_CACHE=1
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ env.SOURCE_SHA }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.created=${{ github.run_id }}
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: ${{ inputs.dry_run && 'type=cacheonly' || 'type=registry,compression=zstd' }}
outputs: type=registry,compression=gzip,compression-level=9
- name: Cleanup Docker
if: always()
+158 -225
View File
@@ -23,57 +23,43 @@ on:
- file
- release
- submit
source_ref:
description: "Git ref/SHA to build (defaults to the workflow ref)"
required: false
default: ""
workflow_call:
inputs:
build_type:
description: "Platform to build for (all, windows, linux, macos)"
required: true
type: string
artifact_destination:
description: "What to do with the built app (none, file, release, submit)"
required: true
type: string
release_tag:
description: "Explicit release tag to upload assets to (defaults to latest release when empty)"
required: false
type: string
default: ""
source_ref:
description: "Git ref/SHA to build"
required: false
type: string
default: ""
outputs:
macos_universal_dmg_sha256:
description: "SHA256 of the universal macOS DMG (for Homebrew cask)"
value: ${{ jobs.build-macos.outputs.dmg_sha256 }}
jobs:
build-windows:
runs-on: blacksmith-4vcpu-windows-2025
if: (inputs.build_type == 'all' || inputs.build_type == 'windows' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
runs-on: windows-latest
if: (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'windows' || github.event.inputs.build_type == '') && github.event.inputs.artifact_destination != 'submit'
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v5
with:
ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
cache: "npm"
- name: Install dependencies
run: npm ci
run: |
$maxAttempts = 3
$attempt = 1
while ($attempt -le $maxAttempts) {
try {
npm ci
break
} catch {
if ($attempt -eq $maxAttempts) {
Write-Error "npm ci failed after $maxAttempts attempts"
exit 1
}
Start-Sleep -Seconds 10
$attempt++
}
}
- name: Get version
id: package-version
@@ -87,32 +73,32 @@ jobs:
run: npm run build && npx electron-builder --win --x64 --ia32
- name: Upload Windows x64 NSIS Installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_x64_nsis.exe') != '' && inputs.artifact_destination != 'none'
uses: actions/upload-artifact@v4
if: hashFiles('release/termix_windows_x64_nsis.exe') != '' && github.event.inputs.artifact_destination != 'none'
with:
name: termix_windows_x64_nsis
path: release/termix_windows_x64_nsis.exe
retention-days: 30
- name: Upload Windows ia32 NSIS Installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_ia32_nsis.exe') != '' && inputs.artifact_destination != 'none'
uses: actions/upload-artifact@v4
if: hashFiles('release/termix_windows_ia32_nsis.exe') != '' && github.event.inputs.artifact_destination != 'none'
with:
name: termix_windows_ia32_nsis
path: release/termix_windows_ia32_nsis.exe
retention-days: 30
- name: Upload Windows x64 MSI Installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_x64_msi.msi') != '' && inputs.artifact_destination != 'none'
uses: actions/upload-artifact@v4
if: hashFiles('release/termix_windows_x64_msi.msi') != '' && github.event.inputs.artifact_destination != 'none'
with:
name: termix_windows_x64_msi
path: release/termix_windows_x64_msi.msi
retention-days: 30
- name: Upload Windows ia32 MSI Installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_ia32_msi.msi') != '' && inputs.artifact_destination != 'none'
uses: actions/upload-artifact@v4
if: hashFiles('release/termix_windows_ia32_msi.msi') != '' && github.event.inputs.artifact_destination != 'none'
with:
name: termix_windows_ia32_msi
path: release/termix_windows_ia32_msi.msi
@@ -129,48 +115,57 @@ jobs:
Compress-Archive -Path "release\win-ia32-unpacked\*" -DestinationPath "termix_windows_ia32_portable.zip"
- name: Upload Windows x64 Portable
uses: actions/upload-artifact@v7
if: hashFiles('termix_windows_x64_portable.zip') != '' && inputs.artifact_destination != 'none'
uses: actions/upload-artifact@v4
if: hashFiles('termix_windows_x64_portable.zip') != '' && github.event.inputs.artifact_destination != 'none'
with:
name: termix_windows_x64_portable
path: termix_windows_x64_portable.zip
retention-days: 30
- name: Upload Windows ia32 Portable
uses: actions/upload-artifact@v7
if: hashFiles('termix_windows_ia32_portable.zip') != '' && inputs.artifact_destination != 'none'
uses: actions/upload-artifact@v4
if: hashFiles('termix_windows_ia32_portable.zip') != '' && github.event.inputs.artifact_destination != 'none'
with:
name: termix_windows_ia32_portable
path: termix_windows_ia32_portable.zip
retention-days: 30
build-linux:
runs-on: blacksmith-8vcpu-ubuntu-2404
if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
runs-on: blacksmith-4vcpu-ubuntu-2404
if: (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'linux' || github.event.inputs.build_type == '') && github.event.inputs.artifact_destination != 'submit'
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v5
with:
ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
cache: "npm"
- name: Install system dependencies
- name: Install system dependencies for AppImage
run: |
sudo apt-get update
sudo apt-get install -y libfuse2 flatpak flatpak-builder imagemagick
sudo apt-get install -y libfuse2
- name: Install dependencies
run: |
npm ci
for i in 1 2 3;
do
if npm ci; then
break
else
if [ $i -eq 3 ]; then
exit 1
fi
sleep 10
fi
done
npm install --force @rollup/rollup-linux-x64-gnu
npm install --force @rollup/rollup-linux-arm64-gnu
npm install --force @rollup/rollup-linux-arm-gnueabihf
@@ -201,77 +196,82 @@ jobs:
cd ..
- name: Upload Linux x64 AppImage
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_x64_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
uses: actions/upload-artifact@v4
if: hashFiles('release/termix_linux_x64_appimage.AppImage') != '' && github.event.inputs.artifact_destination != 'none'
with:
name: termix_linux_x64_appimage
path: release/termix_linux_x64_appimage.AppImage
retention-days: 30
- name: Upload Linux arm64 AppImage
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_arm64_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
uses: actions/upload-artifact@v4
if: hashFiles('release/termix_linux_arm64_appimage.AppImage') != '' && github.event.inputs.artifact_destination != 'none'
with:
name: termix_linux_arm64_appimage
path: release/termix_linux_arm64_appimage.AppImage
retention-days: 30
- name: Upload Linux armv7l AppImage
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_armv7l_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
uses: actions/upload-artifact@v4
if: hashFiles('release/termix_linux_armv7l_appimage.AppImage') != '' && github.event.inputs.artifact_destination != 'none'
with:
name: termix_linux_armv7l_appimage
path: release/termix_linux_armv7l_appimage.AppImage
retention-days: 30
- name: Upload Linux x64 DEB
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_x64_deb.deb') != '' && inputs.artifact_destination != 'none'
uses: actions/upload-artifact@v4
if: hashFiles('release/termix_linux_x64_deb.deb') != '' && github.event.inputs.artifact_destination != 'none'
with:
name: termix_linux_x64_deb
path: release/termix_linux_x64_deb.deb
retention-days: 30
- name: Upload Linux arm64 DEB
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_arm64_deb.deb') != '' && inputs.artifact_destination != 'none'
uses: actions/upload-artifact@v4
if: hashFiles('release/termix_linux_arm64_deb.deb') != '' && github.event.inputs.artifact_destination != 'none'
with:
name: termix_linux_arm64_deb
path: release/termix_linux_arm64_deb.deb
retention-days: 30
- name: Upload Linux armv7l DEB
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_armv7l_deb.deb') != '' && inputs.artifact_destination != 'none'
uses: actions/upload-artifact@v4
if: hashFiles('release/termix_linux_armv7l_deb.deb') != '' && github.event.inputs.artifact_destination != 'none'
with:
name: termix_linux_armv7l_deb
path: release/termix_linux_armv7l_deb.deb
retention-days: 30
- name: Upload Linux x64 tar.gz
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_x64_portable.tar.gz') != '' && inputs.artifact_destination != 'none'
uses: actions/upload-artifact@v4
if: hashFiles('release/termix_linux_x64_portable.tar.gz') != '' && github.event.inputs.artifact_destination != 'none'
with:
name: termix_linux_x64_portable
path: release/termix_linux_x64_portable.tar.gz
retention-days: 30
- name: Upload Linux arm64 tar.gz
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_arm64_portable.tar.gz') != '' && inputs.artifact_destination != 'none'
uses: actions/upload-artifact@v4
if: hashFiles('release/termix_linux_arm64_portable.tar.gz') != '' && github.event.inputs.artifact_destination != 'none'
with:
name: termix_linux_arm64_portable
path: release/termix_linux_arm64_portable.tar.gz
retention-days: 30
- name: Upload Linux armv7l tar.gz
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_armv7l_portable.tar.gz') != '' && inputs.artifact_destination != 'none'
uses: actions/upload-artifact@v4
if: hashFiles('release/termix_linux_armv7l_portable.tar.gz') != '' && github.event.inputs.artifact_destination != 'none'
with:
name: termix_linux_armv7l_portable
path: release/termix_linux_armv7l_portable.tar.gz
retention-days: 30
- name: Install Flatpak builder and dependencies
run: |
sudo apt-get update
sudo apt-get install -y flatpak flatpak-builder imagemagick
- name: Add Flathub repository
run: |
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
@@ -337,46 +337,53 @@ jobs:
sed -i "s|VERSION_PLACEHOLDER|release-${VERSION}-tag|g" release/com.karmaa.termix.flatpakref
- name: Upload Flatpak bundle
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_flatpak.flatpak') != '' && inputs.artifact_destination != 'none'
uses: actions/upload-artifact@v4
if: hashFiles('release/termix_linux_flatpak.flatpak') != '' && github.event.inputs.artifact_destination != 'none'
with:
name: termix_linux_flatpak
path: release/termix_linux_flatpak.flatpak
retention-days: 30
- name: Upload Flatpakref
uses: actions/upload-artifact@v7
if: hashFiles('release/com.karmaa.termix.flatpakref') != '' && inputs.artifact_destination != 'none'
uses: actions/upload-artifact@v4
if: hashFiles('release/com.karmaa.termix.flatpakref') != '' && github.event.inputs.artifact_destination != 'none'
with:
name: termix_linux_flatpakref
path: release/com.karmaa.termix.flatpakref
retention-days: 30
build-macos:
runs-on: blacksmith-6vcpu-macos-latest
if: (inputs.build_type == 'macos' || inputs.build_type == 'all') && inputs.artifact_destination != 'submit'
runs-on: macos-latest
if: (github.event.inputs.build_type == 'macos' || github.event.inputs.build_type == 'all') && github.event.inputs.artifact_destination != 'submit'
needs: []
permissions:
contents: write
outputs:
dmg_sha256: ${{ steps.dmg-checksum.outputs.sha256 }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v5
with:
ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
cache: "npm"
- name: Install dependencies
run: |
npm ci
for i in 1 2 3;
do
if npm ci; then
break
else
if [ $i -eq 3 ]; then
exit 1
fi
sleep 10
fi
done
npm install --force @rollup/rollup-darwin-arm64
npm install dmg-license
@@ -483,16 +490,16 @@ jobs:
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
NODE_OPTIONS: --max-old-space-size=4096
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ "${{ steps.check_certs.outputs.has_certs }}" != "true" ]; then
npm run build
fi
export GH_TOKEN="${{ secrets.GITHUB_TOKEN }}"
npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
- name: Upload macOS MAS PKG
if: steps.check_certs.outputs.has_certs == 'true' && hashFiles('release/termix_macos_universal_mas.pkg') != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release' || inputs.artifact_destination == 'submit')
uses: actions/upload-artifact@v7
if: steps.check_certs.outputs.has_certs == 'true' && hashFiles('release/termix_macos_universal_mas.pkg') != '' && (github.event.inputs.artifact_destination == 'file' || github.event.inputs.artifact_destination == 'release' || github.event.inputs.artifact_destination == 'submit')
uses: actions/upload-artifact@v4
with:
name: termix_macos_universal_mas
path: release/termix_macos_universal_mas.pkg
@@ -500,24 +507,24 @@ jobs:
if-no-files-found: warn
- name: Upload macOS Universal DMG
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_macos_universal_dmg.dmg') != '' && inputs.artifact_destination != 'none'
uses: actions/upload-artifact@v4
if: hashFiles('release/termix_macos_universal_dmg.dmg') != '' && github.event.inputs.artifact_destination != 'none'
with:
name: termix_macos_universal_dmg
path: release/termix_macos_universal_dmg.dmg
retention-days: 30
- name: Upload macOS x64 DMG
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_macos_x64_dmg.dmg') != '' && inputs.artifact_destination != 'none'
uses: actions/upload-artifact@v4
if: hashFiles('release/termix_macos_x64_dmg.dmg') != '' && github.event.inputs.artifact_destination != 'none'
with:
name: termix_macos_x64_dmg
path: release/termix_macos_x64_dmg.dmg
retention-days: 30
- name: Upload macOS arm64 DMG
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_macos_arm64_dmg.dmg') != '' && inputs.artifact_destination != 'none'
uses: actions/upload-artifact@v4
if: hashFiles('release/termix_macos_arm64_dmg.dmg') != '' && github.event.inputs.artifact_destination != 'none'
with:
name: termix_macos_arm64_dmg
path: release/termix_macos_arm64_dmg.dmg
@@ -529,15 +536,8 @@ jobs:
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Compute universal DMG checksum
id: dmg-checksum
if: hashFiles('release/termix_macos_universal_dmg.dmg') != ''
run: |
CHECKSUM=$(shasum -a 256 "release/termix_macos_universal_dmg.dmg" | awk '{print $1}')
echo "sha256=$CHECKSUM" >> $GITHUB_OUTPUT
- name: Generate Homebrew Cask
if: hashFiles('release/termix_macos_universal_dmg.dmg') != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release')
if: hashFiles('release/termix_macos_universal_dmg.dmg') != '' && (github.event.inputs.artifact_destination == 'file' || github.event.inputs.artifact_destination == 'release')
run: |
VERSION="${{ steps.homebrew-version.outputs.version }}"
DMG_PATH="release/termix_macos_universal_dmg.dmg"
@@ -554,15 +554,15 @@ jobs:
sed -i '' "s|release-[0-9.]*-tag|release-$VERSION-tag|g" homebrew-generated/termix.rb
- name: Upload Homebrew Cask as artifact
uses: actions/upload-artifact@v7
if: hashFiles('homebrew-generated/termix.rb') != '' && inputs.artifact_destination == 'file'
uses: actions/upload-artifact@v4
if: hashFiles('homebrew-generated/termix.rb') != '' && github.event.inputs.artifact_destination == 'file'
with:
name: termix_macos_homebrew_cask
path: homebrew-generated/termix.rb
retention-days: 30
- name: Upload Homebrew Cask to release
if: hashFiles('homebrew-generated/termix.rb') != '' && inputs.artifact_destination == 'release'
if: hashFiles('homebrew-generated/termix.rb') != '' && github.event.inputs.artifact_destination == 'release'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
@@ -583,16 +583,15 @@ jobs:
security delete-keychain $RUNNER_TEMP/dev-signing.keychain-db || true
submit-to-chocolatey:
runs-on: blacksmith-4vcpu-windows-2025
if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'windows' || inputs.build_type == '')
runs-on: windows-latest
if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'windows' || github.event.inputs.build_type == '')
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v5
with:
ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Get version from package.json
@@ -682,24 +681,23 @@ jobs:
}
- name: Upload Chocolatey package as artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: chocolatey-package
path: choco-build/*.nupkg
retention-days: 30
submit-to-flatpak:
runs-on: blacksmith-8vcpu-ubuntu-2404
if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '')
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'linux' || github.event.inputs.build_type == '')
needs: []
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v5
with:
ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Get version from package.json
@@ -737,7 +735,7 @@ jobs:
echo "appimage_arm64_name=$APPIMAGE_ARM64_NAME" >> $GITHUB_OUTPUT
echo "checksum_arm64=$CHECKSUM_ARM64" >> $GITHUB_OUTPUT
- name: Install dependencies
- name: Install ImageMagick for icon generation
run: |
sudo apt-get update
sudo apt-get install -y imagemagick
@@ -748,6 +746,8 @@ jobs:
CHECKSUM_X64="${{ steps.appimage-info.outputs.checksum_x64 }}"
CHECKSUM_ARM64="${{ steps.appimage-info.outputs.checksum_arm64 }}"
RELEASE_DATE="${{ steps.package-version.outputs.release_date }}"
APPIMAGE_X64_NAME="${{ steps.appimage-info.outputs.appimage_x64_name }}"
APPIMAGE_ARM64_NAME="${{ steps.appimage-info.outputs.appimage_arm64_name }}"
mkdir -p flatpak-submission
@@ -768,76 +768,23 @@ jobs:
sed -i "s/DATE_PLACEHOLDER/$RELEASE_DATE/g" flatpak-submission/com.karmaa.termix.metainfo.xml
- name: Upload Flatpak submission as artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: flatpak-submission
path: flatpak-submission/*
retention-days: 30
- name: Check for Flathub token
id: check_flathub_token
run: |
if [ -n "${{ secrets.FLATHUB_TOKEN }}" ]; then
echo "has_token=true" >> $GITHUB_OUTPUT
fi
- name: Open PR on Flathub repo
if: steps.check_flathub_token.outputs.has_token == 'true'
env:
GH_TOKEN: ${{ secrets.FLATHUB_TOKEN }}
VERSION: ${{ steps.package-version.outputs.version }}
run: |
FLATHUB_REPO="flathub/com.karmaa.termix"
BRANCH="release-$VERSION"
git config --global user.name "LukeGus"
git config --global user.email "bugattiguy527@gmail.com"
git clone "https://x-access-token:${GH_TOKEN}@github.com/${FLATHUB_REPO}.git" flathub-repo
cd flathub-repo
git checkout -b "$BRANCH"
cp ../flatpak-submission/com.karmaa.termix.yml ./
cp ../flatpak-submission/com.karmaa.termix.desktop ./
cp ../flatpak-submission/com.karmaa.termix.metainfo.xml ./
cp ../flatpak-submission/flathub.json ./
cp ../flatpak-submission/com.karmaa.termix.svg ./
cp ../flatpak-submission/icon-256.png ./
cp ../flatpak-submission/icon-128.png ./
if git diff --quiet && git diff --cached --quiet; then
echo "No changes to submit for $VERSION; Flathub repo already up to date."
exit 0
fi
git add -A
git commit -m "Update to $VERSION"
git push origin "$BRANCH"
EXISTING_PR=$(gh pr list --repo "$FLATHUB_REPO" --head "$BRANCH" --state open --json number -q '.[0].number' || true)
if [ -z "$EXISTING_PR" ]; then
gh pr create --repo "$FLATHUB_REPO" \
--base master \
--head "$BRANCH" \
--title "Update to $VERSION" \
--body "Automated release update to version $VERSION."
else
echo "PR #$EXISTING_PR already open for $BRANCH."
fi
submit-to-homebrew:
runs-on: blacksmith-6vcpu-macos-latest
if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'macos')
runs-on: macos-latest
if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'macos')
needs: []
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v5
with:
ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Get version from package.json
@@ -890,36 +837,31 @@ jobs:
ruby -c homebrew-submission/Casks/t/termix.rb
- name: Upload Homebrew submission as artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: homebrew-submission
path: homebrew-submission/*
retention-days: 30
upload-to-release:
runs-on: blacksmith-8vcpu-ubuntu-2404
if: inputs.artifact_destination == 'release'
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.event.inputs.artifact_destination == 'release'
needs: [build-windows, build-linux, build-macos]
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Resolve release tag
- name: Get latest release tag
id: get_release
run: |
echo "RELEASE_TAG=$(gh release list --repo ${{ github.repository }} --limit 1 --json tagName -q '.[0].tagName')" >> $GITHUB_ENV
env:
GH_TOKEN: ${{ github.token }}
INPUT_TAG: ${{ inputs.release_tag }}
run: |
if [ -n "$INPUT_TAG" ]; then
echo "RELEASE_TAG=$INPUT_TAG" >> $GITHUB_ENV
else
echo "RELEASE_TAG=$(gh release list --repo ${{ github.repository }} --limit 1 --json tagName -q '.[0].tagName')" >> $GITHUB_ENV
fi
- name: Upload artifacts to latest release
run: |
@@ -937,29 +879,38 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
submit-to-app-store:
runs-on: blacksmith-6vcpu-macos-latest
if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'macos')
submit-to-testflight:
runs-on: macos-latest
if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'macos')
needs: []
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v5
with:
ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
cache: "npm"
- name: Install dependencies
run: |
npm ci
for i in 1 2 3;
do
if npm ci; then
break
else
if [ $i -eq 3 ]; then
exit 1
fi
sleep 10
fi
done
npm install --force @rollup/rollup-darwin-arm64
npm install dmg-license
@@ -1009,7 +960,9 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=4096
run: |
CURRENT_VERSION=$(node -p "require('./package.json').version")
BUILD_VERSION="${{ github.run_number }}"
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
- name: Check for App Store Connect API credentials
@@ -1020,56 +973,36 @@ jobs:
fi
- name: Setup Ruby for Fastlane
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
if: steps.check_asc_creds.outputs.has_credentials == 'true'
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
ruby-version: "3.2"
bundler-cache: false
- name: Install Fastlane
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
run: gem install fastlane -N
if: steps.check_asc_creds.outputs.has_credentials == 'true'
run: |
gem install fastlane -N
- name: Upload and submit to Mac App Store
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
env:
APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }}
APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }}
- name: Deploy to App Store Connect (TestFlight)
if: steps.check_asc_creds.outputs.has_credentials == 'true'
run: |
PKG_FILE=$(find release -name "termix_macos_universal_mas.pkg" -type f | head -n 1)
if [ -z "$PKG_FILE" ]; then
echo "PKG file not found in release/; aborting."
echo "PKG file not found, exiting."
exit 1
fi
VERSION=$(node -p "require('./package.json').version")
mkdir -p ~/private_keys
echo "${{ secrets.APPLE_KEY_CONTENT }}" | base64 --decode > ~/private_keys/AuthKey_${{ secrets.APPLE_KEY_ID }}.p8
# Write API key JSON that Fastlane deliver expects
mkdir -p /tmp/asc_keys
KEY_P8_PATH="/tmp/asc_keys/AuthKey_${APPLE_KEY_ID}.p8"
API_KEY_JSON="/tmp/asc_keys/api_key.json"
echo "$APPLE_KEY_CONTENT" | base64 --decode > "$KEY_P8_PATH"
printf '{\n "key_id": "%s",\n "issuer_id": "%s",\n "key": "%s",\n "in_house": false\n}\n' \
"$APPLE_KEY_ID" \
"$APPLE_ISSUER_ID" \
"$(tr -d '\n' < "$KEY_P8_PATH")" \
> "$API_KEY_JSON"
fastlane deliver \
--pkg "$PKG_FILE" \
--api_key_path "$API_KEY_JSON" \
--app_version "$VERSION" \
--skip_metadata true \
--skip_screenshots true \
--submit_for_review true \
--automatic_release true \
--force true
xcrun altool --upload-app -f "$PKG_FILE" \
--type macos \
--apiKey "${{ secrets.APPLE_KEY_ID }}" \
--apiIssuer "${{ secrets.APPLE_ISSUER_ID }}"
continue-on-error: true
- name: Clean up keychains
if: always()
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
rm -rf /tmp/asc_keys
+3 -3
View File
@@ -10,10 +10,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
cache: "npm"
@@ -25,7 +25,7 @@ jobs:
run: npm run generate:openapi
- name: Upload OpenAPI artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: openapi-spec
path: openapi.json
+2 -3
View File
@@ -13,13 +13,12 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
cache: "npm"
- name: Install dependencies
run: npm ci
-551
View File
@@ -1,551 +0,0 @@
name: Release
on:
workflow_dispatch:
inputs:
mode:
description: "Release mode"
required: true
default: Everything
type: choice
options:
- Everything
- Overwrite release
- Dry run
- Skip submit
permissions:
contents: write
jobs:
prep:
runs-on: blacksmith-2vcpu-ubuntu-2404
outputs:
version: ${{ steps.info.outputs.version }}
release_tag: ${{ steps.info.outputs.release_tag }}
mobile_version: ${{ steps.info.outputs.mobile_version }}
dev_branch: ${{ steps.info.outputs.dev_branch }}
steps:
- name: Guard branch (release modes)
if: ${{ inputs.mode != 'Overwrite release' && !startsWith(github.ref, 'refs/heads/dev-') }}
run: |
echo "This mode must be run from a dev branch (got ${{ github.ref }})."
exit 1
- name: Guard branch (overwrite mode)
if: ${{ inputs.mode == 'Overwrite release' && github.ref != 'refs/heads/main' }}
run: |
echo "Overwrite release must be run from main (got ${{ github.ref }})."
exit 1
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
- name: Resolve versions
id: info
env:
GH_TOKEN: ${{ github.token }}
run: |
if [ "${{ inputs.mode }}" = "Overwrite release" ]; then
DEV_BRANCH=""
VERSION=$(node -p "require('./package.json').version")
else
DEV_BRANCH="${GITHUB_REF#refs/heads/}"
VERSION=$(node scripts/parse-dev-branch.cjs "$DEV_BRANCH")
fi
echo "dev_branch=$DEV_BRANCH" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "release_tag=release-$VERSION-tag" >> "$GITHUB_OUTPUT"
MOBILE_RAW=$(gh release view -R Termix-SSH/Mobile --json tagName -q .tagName)
MOBILE_VERSION=$(echo "$MOBILE_RAW" | sed -E 's/^release-//; s/-tag$//')
if [ -z "$MOBILE_VERSION" ]; then
echo "Failed to resolve the latest Termix-SSH/Mobile release version."
exit 1
fi
echo "mobile_version=$MOBILE_VERSION" >> "$GITHUB_OUTPUT"
- name: Validate release notes
run: |
node scripts/generate-release-body.cjs \
--version "${{ steps.info.outputs.version }}" \
--mobile-version "${{ steps.info.outputs.mobile_version }}" \
--notes RELEASE_NOTES.md > /dev/null
normalize:
needs: [prep]
if: ${{ inputs.mode != 'Overwrite release' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout dev branch
uses: actions/checkout@v7
with:
ref: ${{ needs.prep.outputs.dev_branch }}
fetch-depth: 0
token: ${{ secrets.GHCR_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Lint and format
run: |
npm run lint:fix || true
npm run format
- name: Run unit tests
run: npm run test
- name: Sync version
run: node scripts/sync-version.cjs --version "${{ needs.prep.outputs.version }}"
- name: Commit changes to dev branch
run: |
git config user.name "LukeGus"
git config user.email "bugattiguy527@gmail.com"
if git diff --quiet; then
echo "No lint/format/version changes to commit."
exit 0
fi
if [ "${{ inputs.mode }}" = "Dry run" ]; then
echo "DRY RUN: would commit and push the following changes to ${{ needs.prep.outputs.dev_branch }}:"
git --no-pager diff --stat
exit 0
fi
git add -A
git commit -m "chore: lint, format, and bump version to ${{ needs.prep.outputs.version }}"
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
crowdin:
needs: [prep, normalize]
if: ${{ inputs.mode != 'Dry run' && inputs.mode != 'Overwrite release' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout dev branch
uses: actions/checkout@v7
with:
ref: ${{ needs.prep.outputs.dev_branch }}
fetch-depth: 0
token: ${{ secrets.GHCR_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
- name: Merge main into dev branch
run: |
git config user.name "LukeGus"
git config user.email "bugattiguy527@gmail.com"
git fetch origin main
git merge origin/main -X ours --no-edit || true
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
- name: Clean up stale Crowdin git integration branch
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
run: |
PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --head i18n_translate --state open --json number -q '.[0].number' || true)
if [ -n "$PR_NUMBER" ]; then
gh pr close "$PR_NUMBER" --repo ${{ github.repository }} --delete-branch || true
fi
git push origin --delete i18n_translate || true
- name: Upload sources to Crowdin
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: false
download_translations: false
create_pull_request: false
push_translations: false
token: ${{ secrets.CROWDIN_API_KEY }}
project_id: "858252"
env:
CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_KEY }}
- name: Machine pre-translate untranslated strings
env:
CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}
run: node scripts/crowdin-pretranslate.cjs
- name: Download translations from Crowdin
uses: crowdin/github-action@v2
with:
upload_sources: false
upload_translations: false
download_translations: true
create_pull_request: false
push_translations: false
token: ${{ secrets.CROWDIN_API_KEY }}
project_id: "858252"
env:
CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_KEY }}
- name: Commit translations to dev branch
run: |
git config user.name "LukeGus"
git config user.email "bugattiguy527@gmail.com"
git add src/ui/locales/translated
if git diff --cached --quiet; then
echo "No translation changes to commit."
exit 0
fi
git commit -m "chore: sync Crowdin translations for ${{ needs.prep.outputs.version }}"
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
merge-to-main:
needs: [prep, normalize, crowdin]
if: ${{ always() && needs.prep.result == 'success' && (needs.normalize.result == 'success' || needs.normalize.result == 'skipped') && (needs.crowdin.result == 'success' || needs.crowdin.result == 'skipped') }}
runs-on: blacksmith-2vcpu-ubuntu-2404
outputs:
main_sha: ${{ steps.merge.outputs.main_sha }}
build_ref: ${{ steps.merge.outputs.build_ref }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
- name: Generate PR body
id: body
run: |
node scripts/generate-release-body.cjs \
--version "${{ needs.prep.outputs.version }}" \
--mobile-version "${{ needs.prep.outputs.mobile_version }}" \
--notes RELEASE_NOTES.md > PR_BODY.md
- name: Open and squash-merge PR to main
id: merge
env:
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
run: |
DEV_BRANCH="${{ needs.prep.outputs.dev_branch }}"
TITLE="release-${{ needs.prep.outputs.version }}"
if [ "${{ inputs.mode }}" = "Overwrite release" ]; then
echo "OVERWRITE: no merge; building from main as-is."
echo "build_ref=main" >> "$GITHUB_OUTPUT"
echo "main_sha=$(gh api repos/${{ github.repository }}/commits/main -q .sha)" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "${{ inputs.mode }}" = "Dry run" ]; then
echo "DRY RUN: would open and squash-merge a PR from $DEV_BRANCH into main."
echo "DRY RUN: downstream jobs will build from $DEV_BRANCH instead of main."
echo "build_ref=$DEV_BRANCH" >> "$GITHUB_OUTPUT"
echo "main_sha=" >> "$GITHUB_OUTPUT"
exit 0
fi
PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --head "$DEV_BRANCH" --base main --state open --json number -q '.[0].number' || true)
if [ -z "$PR_NUMBER" ]; then
PR_NUMBER=$(gh pr create --repo ${{ github.repository }} \
--base main --head "$DEV_BRANCH" \
--title "$TITLE" --body-file PR_BODY.md \
| grep -oE '[0-9]+$')
fi
gh pr merge "$PR_NUMBER" --repo ${{ github.repository }} --squash --admin
STATE=$(gh pr view "$PR_NUMBER" --repo ${{ github.repository }} --json state -q .state)
if [ "$STATE" != "MERGED" ]; then
echo "PR #$PR_NUMBER did not merge (state: $STATE)."
exit 1
fi
MAIN_SHA=$(gh api repos/${{ github.repository }}/commits/main -q .sha)
echo "main_sha=$MAIN_SHA" >> "$GITHUB_OUTPUT"
echo "build_ref=$MAIN_SHA" >> "$GITHUB_OUTPUT"
docker:
needs: [prep, merge-to-main]
uses: ./.github/workflows/docker.yml
with:
version: ${{ needs.prep.outputs.version }}
build_type: Production
dry_run: ${{ inputs.mode == 'Dry run' }}
source_ref: ${{ needs.merge-to-main.outputs.build_ref }}
secrets: inherit
create-release:
needs: [prep, merge-to-main, docker]
if: ${{ inputs.mode != 'Dry run' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
contents: write
steps:
- name: Checkout main
uses: actions/checkout@v7
with:
ref: main
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
- name: Clear existing release artifacts (overwrite mode)
if: ${{ inputs.mode == 'Overwrite release' }}
env:
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
run: |
TAG="${{ needs.prep.outputs.release_tag }}"
if ! gh release view "$TAG" --repo ${{ github.repository }} >/dev/null 2>&1; then
echo "Overwrite release: no existing release $TAG to overwrite."
exit 1
fi
echo "Clearing existing assets from $TAG..."
gh release view "$TAG" --repo ${{ github.repository }} --json assets -q '.assets[].name' | while read -r ASSET; do
[ -n "$ASSET" ] && gh release delete-asset "$TAG" "$ASSET" --repo ${{ github.repository }} --yes || true
done
- name: Generate release body
if: ${{ inputs.mode != 'Overwrite release' }}
run: |
node scripts/generate-release-body.cjs \
--version "${{ needs.prep.outputs.version }}" \
--mobile-version "${{ needs.prep.outputs.mobile_version }}" \
--notes RELEASE_NOTES.md > RELEASE_BODY.md
- name: Create or update GitHub release
if: ${{ inputs.mode != 'Overwrite release' }}
env:
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
run: |
TAG="${{ needs.prep.outputs.release_tag }}"
TITLE="release-${{ needs.prep.outputs.version }}"
if gh release view "$TAG" --repo ${{ github.repository }} >/dev/null 2>&1; then
gh release edit "$TAG" --repo ${{ github.repository }} --title "$TITLE" --notes-file RELEASE_BODY.md
else
gh release create "$TAG" --repo ${{ github.repository }} --title "$TITLE" --notes-file RELEASE_BODY.md --target "${{ needs.merge-to-main.outputs.main_sha }}"
fi
electron-release:
needs: [prep, merge-to-main, create-release]
if: ${{ always() && needs.merge-to-main.result == 'success' && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }}
uses: ./.github/workflows/electron.yml
with:
build_type: all
artifact_destination: ${{ inputs.mode == 'Dry run' && 'file' || 'release' }}
release_tag: ${{ needs.prep.outputs.release_tag }}
source_ref: ${{ needs.merge-to-main.outputs.build_ref }}
secrets: inherit
electron-submit:
needs: [prep, merge-to-main, electron-release]
if: ${{ inputs.mode != 'Dry run' && inputs.mode != 'Skip submit' }}
uses: ./.github/workflows/electron.yml
with:
build_type: all
artifact_destination: submit
source_ref: ${{ needs.merge-to-main.outputs.build_ref }}
secrets: inherit
cask-commit-back:
needs: [prep, electron-release]
if: ${{ inputs.mode != 'Dry run' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
contents: write
steps:
- name: Checkout main
uses: actions/checkout@v7
with:
ref: main
fetch-depth: 1
token: ${{ secrets.GHCR_TOKEN }}
- name: Bump and commit Homebrew cask
env:
VERSION: ${{ needs.prep.outputs.version }}
DMG_SHA256: ${{ needs.electron-release.outputs.macos_universal_dmg_sha256 }}
run: |
if [ -z "$DMG_SHA256" ]; then
echo "No universal DMG checksum available (macOS build unsigned or skipped); leaving cask unchanged."
exit 0
fi
sed -i "s|version \".*\"|version \"$VERSION\"|g" Casks/termix.rb
sed -i "s|sha256 \".*\"|sha256 \"$DMG_SHA256\"|g" Casks/termix.rb
if git diff --quiet Casks/termix.rb; then
echo "Cask already up to date."
exit 0
fi
git config user.name "LukeGus"
git config user.email "bugattiguy527@gmail.com"
git add Casks/termix.rb
git stash
git pull --rebase origin main
git stash pop
git commit -m "chore: bump Homebrew cask to $VERSION"
git push origin HEAD:main
docs:
needs: [prep, merge-to-main]
if: ${{ always() && needs.merge-to-main.result == 'success' && inputs.mode != 'Overwrite release' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout Termix
uses: actions/checkout@v7
with:
ref: ${{ needs.merge-to-main.outputs.build_ref }}
fetch-depth: 1
path: termix
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: "termix/.nvmrc"
cache: "npm"
cache-dependency-path: termix/package-lock.json
- name: Generate OpenAPI spec
working-directory: termix
run: |
npm ci
npm run generate:openapi
- name: Checkout Docs repository
uses: actions/checkout@v7
with:
repository: Termix-SSH/Docs
ref: main
fetch-depth: 0
token: ${{ secrets.GHCR_TOKEN }}
path: docs-repo
- name: Create docs release branch
working-directory: docs-repo
run: git checkout -B "dev-${{ needs.prep.outputs.version }}"
- name: Overwrite OpenAPI spec and regenerate API docs
working-directory: docs-repo
run: |
cp ../termix/openapi.json static/openapi.json
npm ci
npm run docusaurus gen-api-docs termix
- name: Commit and push docs branch
working-directory: docs-repo
run: |
git config user.name "LukeGus"
git config user.email "bugattiguy527@gmail.com"
if git diff --quiet; then
echo "No docs changes to commit."
exit 0
fi
if [ "${{ inputs.mode }}" = "Dry run" ]; then
echo "DRY RUN: would commit and push the following docs changes to dev-${{ needs.prep.outputs.version }}:"
git --no-pager diff --stat
exit 0
fi
git add -A
git commit -m "feat: update API docs for ${{ needs.prep.outputs.version }}"
git push --force origin "dev-${{ needs.prep.outputs.version }}"
- name: Open and squash-merge docs PR
if: ${{ inputs.mode != 'Dry run' }}
working-directory: docs-repo
env:
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
run: |
BRANCH="dev-${{ needs.prep.outputs.version }}"
if ! git ls-remote --exit-code origin "refs/heads/$BRANCH" >/dev/null 2>&1; then
echo "No docs branch pushed (nothing changed); skipping PR."
exit 0
fi
PR_NUMBER=$(gh pr list --repo Termix-SSH/Docs --head "$BRANCH" --base main --state open --json number -q '.[0].number' || true)
if [ -z "$PR_NUMBER" ]; then
PR_URL=$(gh pr create --repo Termix-SSH/Docs \
--base main --head "$BRANCH" \
--title "release-${{ needs.prep.outputs.version }}" \
--body "API docs for ${{ needs.prep.outputs.version }}")
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$')
fi
if [ -z "$PR_NUMBER" ]; then
echo "Failed to find or create a PR for $BRANCH."
exit 1
fi
gh pr merge "$PR_NUMBER" --repo Termix-SSH/Docs --squash --admin
publish-youtube:
needs: [prep, electron-release]
if: ${{ always() && inputs.mode != 'Dry run' && inputs.mode != 'Overwrite release' && needs.electron-release.result == 'success' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout main
uses: actions/checkout@v7
with:
ref: main
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
- name: Set release video to public
env:
YOUTUBE_CLIENT_ID: ${{ secrets.YOUTUBE_CLIENT_ID }}
YOUTUBE_CLIENT_SECRET: ${{ secrets.YOUTUBE_CLIENT_SECRET }}
YOUTUBE_REFRESH_TOKEN: ${{ secrets.YOUTUBE_REFRESH_TOKEN }}
run: node scripts/publish-youtube.cjs
cleanup:
needs:
[
prep,
merge-to-main,
electron-release,
cask-commit-back,
docs,
publish-youtube,
]
if: ${{ always() && (inputs.mode == 'Everything' || inputs.mode == 'Skip submit') && needs.merge-to-main.result == 'success' && needs.electron-release.result == 'success' && needs.cask-commit-back.result == 'success' && needs.docs.result == 'success' && needs.publish-youtube.result == 'success' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Delete dev branch in Termix
env:
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
run: |
git push https://x-access-token:${{ secrets.GHCR_TOKEN }}@github.com/${{ github.repository }}.git \
--delete "${{ needs.prep.outputs.dev_branch }}" || true
- name: Delete dev branch in Docs
env:
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
run: |
git push https://x-access-token:${{ secrets.GHCR_TOKEN }}@github.com/Termix-SSH/Docs.git \
--delete "dev-${{ needs.prep.outputs.version }}" || true
-4
View File
@@ -7,10 +7,8 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
src/mcp-server/node_modules
dist
dist-ssr
coverage
*.local
.vscode/
@@ -33,5 +31,3 @@ electron/build-info.cjs
/.mcp.json
/CLAUDE.md
/old_db/
/scripts/fix-bugs.mjs
/scripts/fix-features.mjs
-1
View File
@@ -5,7 +5,6 @@ dist-ssr
release
node_modules
src/mcp-server/node_modules
package-lock.json
pnpm-lock.yaml
yarn.lock
+2 -2
View File
@@ -1,6 +1,6 @@
cask "termix" do
version "2.5.0"
sha256 "0662380b3cc986e5ddab263122e87b03a581bc18dbff1ad13e11dc973c84984b"
version "2.2.1"
sha256 "7fe7bde53df568c3212a212e6dc0ca4b77c24a3d3d9b740dddadcd765330e93d"
url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_dmg.dmg"
name "Termix"
+9 -64
View File
@@ -28,17 +28,8 @@
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
</p>
<p>
<a href="https://donate.termix.site/"><img alt="Donations this month" src="https://img.shields.io/badge/dynamic/json?style=for-the-badge&label=Donations%20this%20month&query=%24.fiatTotal&prefix=%24&url=https%3A%2F%2Ftermix.site%2Fdonation-snapshot.json&color=F39044&labelColor=1a1a1a" /></a>
</p>
<br />
Termix is free and open source. If you find it useful, consider [donating](https://donate.termix.site/) to help cover server costs and development time.
<br />
<img src="./repo-images/Termix Header.png" alt="Termix Banner" width="900" />
@@ -89,72 +80,44 @@ Create and manage server-to-server SSH tunnels with automatic reconnection, heal
<td width="50%" valign="top">
**Remote File Manager:**
Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly with sudo support. Includes support for moving files from server to server.
Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly with sudo support.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
**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.
</td>
<td width="50%" valign="top">
**SSH Host Manager:**
Save, organize, and manage your SSH connections with tags and folders (folder customization and nested folder support), and easily save reusable login info while being able to automate the deployment of SSH keys.
Save, organize, and manage your SSH connections with tags and folders, and easily save reusable login info while being able to automate the deployment of SSH keys.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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. Includes time-series history graphs and threshold-based alerts with ntfy and webhook support.
**Server Stats:**
View CPU, memory, and disk usage along with network, uptime, system information, firewall, port monitor, on most Linux based servers.
</td>
<td width="50%" valign="top">
**User Authentication:**
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.
Secure user management with admin controls and OIDC (with access control) and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Tailscale Integration:**
List devices from your tailnet to quickly add them as hosts, and connect using Tailscale SSH as an authentication method, letting your tailnet ACLs handle authorization without storing credentials.
</td>
<td width="50%" valign="top">
**RBAC:**
Create roles and share hosts across users/roles.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
**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.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
@@ -207,9 +170,7 @@ Built-in support ~30 languages (managed by [Crowdin](https://docs.termix.site/tr
- **Command History** - Auto-complete and view previously ran SSH commands
- **Quick Connect** - Connect to a server without having to save the connection data
- **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard
- **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, 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.
- **SSH Feature Rich** - Supports jump hosts, Warpgate, TOTP based connections, SOCKS5, host key verification, password autofill, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc.
</details>
@@ -293,14 +254,6 @@ networks:
<br />
## Donate
Termix is free and open source with no subscriptions or paid plans. If you find it useful, consider donating to help cover server costs, domains, and development time.
[Donate](https://donate.termix.site/)
<br />
## Screenshots
<div align="center">
@@ -343,10 +296,6 @@ Termix is free and open source with no subscriptions or paid plans. If you find
<td><img src="./repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
<td><img src="./repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
</tr>
<tr>
<td><img src="./repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
<td><img src="./repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
</tr>
</table>
<sub>Some videos and images may be out of date or may not perfectly showcase features.</sub>
@@ -357,7 +306,7 @@ Termix is free and open source with no subscriptions or paid plans. If you find
## Planned Features
See [Projects](https://github.com/orgs/Termix-SSH/projects/5) for all planned features. If you are looking to contribute, see [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
See [Projects](https://github.com/orgs/Termix-SSH/projects/2) for all planned features. If you are looking to contribute, see [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
<br />
@@ -394,10 +343,6 @@ See [Projects](https://github.com/orgs/Termix-SSH/projects/5) for all planned fe
<a href="https://aws.amazon.com/">
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="40" alt="AWS" />
</a>
&nbsp;&nbsp;&nbsp;
<a href="https://rackgenius.com/">
<img src="https://rackgenius.com/rackgenius-logo.png" height="40" alt="AWS" />
</a>
</div>
-81
View File
@@ -1,81 +0,0 @@
<!-- SUMMARY -->
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.
<!-- /SUMMARY -->
<!-- YOUTUBE -->
https://youtu.be/c3UD4q2jW_8
<!-- /YOUTUBE -->
<!-- UPDATE_LOG -->
- 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
<!-- /UPDATE_LOG -->
<!-- BUG_FIXES -->
- 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
<!-- /BUG_FIXES -->
-58
View File
@@ -1,58 +0,0 @@
{
"$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
}
}
-12
View File
@@ -1,12 +0,0 @@
const fs = require("fs");
const path = require("path");
exports.default = async function afterPack(context) {
const { targets, appOutDir } = context;
const isDir = targets.some((t) => t.name === "dir");
if (!isDir) return;
const markerPath = path.join(appOutDir, ".portable");
fs.writeFileSync(markerPath, "");
};
+9 -3
View File
@@ -14,10 +14,16 @@
<projectSourceUrl>https://github.com/Termix-SSH/Termix</projectSourceUrl>
<docsUrl>https://docs.termix.site/install</docsUrl>
<bugTrackerUrl>https://github.com/Termix-SSH/Support/issues</bugTrackerUrl>
<tags>docker ssh terminal telnet self-hosted rdp file-management vnc ssh-tunnel server-stats termix</tags>
<summary>Self-hosted SSH and remote desktop management.</summary>
<tags>docker ssh self-hosted file-management ssh-tunnel termix server-management terminal</tags>
<summary>Termix is a web-based server management platform with SSH terminal, tunneling, and file editing capabilities.</summary>
<description>
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a multi-platform solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal access, remote desktop control (RDP, VNC, Telnet), SSH tunneling capabilities, remote file management, and many other tools. Termix is the perfect free and self-hosted alternative to Termius available for all platforms.
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based solution for managing your servers and infrastructure through a single, intuitive interface.
Termix offers:
- SSH terminal access
- SSH tunneling capabilities
- Remote file management
- Server monitoring and management
This package installs the desktop application version of Termix.
</description>
-3
View File
@@ -1,6 +1,3 @@
project_id: "858252"
api_token: "env:CROWDIN_API_TOKEN"
files:
- source: /src/ui/locales/en.json
translation: /src/ui/locales/translated/%locale_with_underscore%.json
+6 -12
View File
@@ -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/*
@@ -8,10 +8,7 @@ COPY package*.json ./
COPY .npmrc ./
COPY vendor ./vendor
COPY scripts/patch-guacamole-lite.cjs ./scripts/
RUN npm ci --ignore-scripts && \
node scripts/patch-guacamole-lite.cjs && \
npm cache clean --force
# Stage 2: Build frontend
@@ -36,7 +33,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/*
@@ -45,22 +42,19 @@ COPY package*.json ./
COPY .npmrc ./
COPY vendor ./vendor
COPY scripts/patch-guacamole-lite.cjs ./scripts/
RUN npm ci --omit=dev --ignore-scripts && \
node scripts/patch-guacamole-lite.cjs && \
npm rebuild better-sqlite3 bcryptjs ssh2 && \
npm rebuild better-sqlite3 bcryptjs && \
npm cache clean --force
# Stage 5: Final optimized image
FROM node:26-slim
FROM node:24-slim
WORKDIR /app
ENV DATA_DIR=/app/data \
PORT=8080 \
NODE_ENV=production
RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget certbot python3-certbot-dns-cloudflare && \
RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget && \
update-ca-certificates && \
rm -rf /var/lib/apt/lists/* && \
mkdir -p /app/data /app/uploads /app/data/.opk /app/nginx /tmp/nginx && \
@@ -78,7 +72,7 @@ COPY --chown=node:node package.json ./
VOLUME ["/app/data"]
EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006 30007 30008 30009 30010 30011 30012
EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD wget -q -O /dev/null http://localhost:30001/health || exit 1
-4
View File
@@ -12,8 +12,6 @@ services:
environment:
PORT: "8080"
NODE_ENV: development
GUACD_HOST: "guacd-dev"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
depends_on:
- guacd-dev
networks:
@@ -23,8 +21,6 @@ services:
image: guacamole/guacd:1.6.0
container_name: guacd-dev
restart: unless-stopped
volumes:
- termix-dev-data:/termix-data
networks:
- termix-dev-net
-3
View File
@@ -10,7 +10,6 @@ services:
environment:
PORT: "8080"
GUACD_HOST: "guacd"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
depends_on:
- guacd
networks:
@@ -20,8 +19,6 @@ services:
image: guacamole/guacd:1.6.0
container_name: guacd
restart: unless-stopped
volumes:
- termix-data:/termix-data
networks:
- termix-net
+4 -11
View File
@@ -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 /app/html /tmp/nginx 2>/dev/null || true
chown -R node:node /app/data /app/uploads /tmp/nginx 2>/dev/null || true
echo "User node is now UID: $PUID, GID: $PGID"
@@ -41,7 +41,7 @@ fi
mkdir -p /tmp/nginx
envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /tmp/nginx/nginx.conf
mkdir -p /app/data /app/uploads /app/data/.opk /app/data/acme-webroot/.well-known/acme-challenge
mkdir -p /app/data /app/uploads /app/data/.opk
chmod 755 /app/data /app/uploads /app/data/.opk 2>/dev/null || true
if [ -w /app/data ]; then
@@ -138,14 +138,7 @@ nginx -c /tmp/nginx/nginx.conf
# Inject runtime BASE_PATH into frontend if configured
if [ -n "$BASE_PATH" ]; then
echo "Injecting BASE_PATH: $BASE_PATH"
# Strip trailing slash for use as a path prefix
CLEAN_BASE_PATH="${BASE_PATH%/}"
find /app/html -name "index.html" -exec sed -i "s|window.__TERMIX_BASE_PATH__ = \"\"|window.__TERMIX_BASE_PATH__ = \"$CLEAN_BASE_PATH\"|g" {} \;
# Patch sw.js static asset paths with the base path prefix
find /app/html -name "sw.js" -exec sed -i "s|__TERMIX_SW_BASE_PATH__|$CLEAN_BASE_PATH|g" {} \;
else
# No base path - replace placeholder with empty string so paths stay absolute from root
find /app/html -name "sw.js" -exec sed -i "s|__TERMIX_SW_BASE_PATH__||g" {} \;
find /app/html -name "index.html" -exec sed -i "s|window.__TERMIX_BASE_PATH__ = \"\"|window.__TERMIX_BASE_PATH__ = \"$BASE_PATH\"|g" {} \;
fi
echo "Starting backend services..."
@@ -167,4 +160,4 @@ node dist/backend/backend/starter.js
echo "All services started"
tail -f /dev/null
tail -f /dev/null
+38 -210
View File
@@ -24,11 +24,7 @@ http {
client_header_timeout 300s;
set_real_ip_from 127.0.0.1;
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
map $http_x_forwarded_proto $proxy_x_forwarded_proto {
default $http_x_forwarded_proto;
@@ -62,8 +58,6 @@ http {
listen ${SSL_PORT} ssl;
server_name _;
absolute_redirect off;
ssl_certificate ${SSL_CERT_PATH};
ssl_certificate_key ${SSL_KEY_PATH};
@@ -71,12 +65,6 @@ http {
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
location ^~ /.well-known/acme-challenge/ {
root /app/data/acme-webroot;
default_type "text/plain";
try_files $uri =404;
}
location = /sw.js {
root /app/html;
expires off;
@@ -138,7 +126,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/releases(/.*)?$ {
@@ -147,7 +135,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/alerts(/.*)?$ {
@@ -156,34 +144,7 @@ http {
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-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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/rbac(/.*)?$ {
@@ -192,7 +153,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/credentials(/.*)?$ {
@@ -201,7 +162,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
@@ -209,24 +170,6 @@ http {
}
location ~ ^/snippets(/.*)?$ {
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 ~ ^/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;
@@ -235,34 +178,13 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/proxmox(/.*)?$ {
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;
proxy_connect_timeout 60s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
location ~ ^/c2s-tunnel-presets(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/audit-logs(/.*)?$ {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/terminal(/.*)?$ {
@@ -271,25 +193,7 @@ http {
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 ~ ^/open-tabs(/.*)?$ {
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 ~ ^/user-preferences(/.*)?$ {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/database(/.*)?$ {
@@ -301,7 +205,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
@@ -320,7 +224,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
@@ -336,7 +240,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /host/quick-connect {
@@ -348,7 +252,7 @@ http {
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_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/host/opkssh-chooser(/.*)?$ {
@@ -387,25 +291,7 @@ http {
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 /session_logs/ {
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 /tailscale/ {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ssh/websocket/ {
@@ -444,9 +330,9 @@ http {
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_set_header X-Forwarded-Port $proxy_x_forwarded_port;
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Host $http_host;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
@@ -464,7 +350,7 @@ http {
proxy_set_header Host $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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /host/tunnel/ {
@@ -473,7 +359,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ssh/tunnel/ {
@@ -484,7 +370,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_buffering off;
@@ -497,7 +383,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /host/file_manager/pinned {
@@ -506,7 +392,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /host/file_manager/shortcuts {
@@ -515,7 +401,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /host/file_manager/sudo-password {
@@ -524,7 +410,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ssh/file_manager/ {
@@ -538,7 +424,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
@@ -559,7 +445,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
@@ -575,7 +461,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /health {
@@ -584,7 +470,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/status(/.*)?$ {
@@ -593,7 +479,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/metrics(/.*)?$ {
@@ -602,7 +488,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
@@ -615,20 +501,7 @@ http {
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 ~ ^/host-metrics(/.*)?$ {
proxy_pass http://127.0.0.1:30005;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/global-settings(/.*)?$ {
@@ -637,7 +510,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/uptime(/.*)?$ {
@@ -646,7 +519,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/activity(/.*)?$ {
@@ -655,25 +528,16 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/service-links(/.*)?$ {
location ~ ^/dashboard/preferences(/.*)?$ {
proxy_pass http://127.0.0.1:30006;
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 ~ ^/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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /docker/console/ {
@@ -687,9 +551,9 @@ http {
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_set_header X-Forwarded-Port $proxy_x_forwarded_port;
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Host $http_host;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
@@ -701,55 +565,19 @@ http {
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
}
# --- tmux-monitor begin ---
location ~ ^/tmux_monitor(/.*)?$ {
proxy_pass http://127.0.0.1:30010;
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;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# --- tmux-monitor end ---
location ~ ^/docker(/.*)?$ {
proxy_pass http://127.0.0.1:30007;
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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
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;
+39 -211
View File
@@ -24,11 +24,7 @@ http {
client_header_timeout 300s;
set_real_ip_from 127.0.0.1;
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
map $http_x_forwarded_proto $proxy_x_forwarded_proto {
default $http_x_forwarded_proto;
@@ -53,19 +49,11 @@ http {
server {
listen ${PORT};
server_name _;
absolute_redirect off;
server_name localhost;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
location ^~ /.well-known/acme-challenge/ {
root /app/data/acme-webroot;
default_type "text/plain";
try_files $uri =404;
}
location = /sw.js {
root /app/html;
expires off;
@@ -127,7 +115,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/releases(/.*)?$ {
@@ -136,7 +124,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/alerts(/.*)?$ {
@@ -145,34 +133,7 @@ http {
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-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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/rbac(/.*)?$ {
@@ -181,7 +142,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/credentials(/.*)?$ {
@@ -190,7 +151,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
@@ -198,24 +159,6 @@ http {
}
location ~ ^/snippets(/.*)?$ {
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 ~ ^/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;
@@ -224,34 +167,13 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/proxmox(/.*)?$ {
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;
proxy_connect_timeout 60s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
location ~ ^/c2s-tunnel-presets(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/audit-logs(/.*)?$ {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/terminal(/.*)?$ {
@@ -260,25 +182,7 @@ http {
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 ~ ^/open-tabs(/.*)?$ {
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 ~ ^/user-preferences(/.*)?$ {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/database(/.*)?$ {
@@ -290,7 +194,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
@@ -309,7 +213,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
@@ -325,7 +229,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /host/quick-connect {
@@ -337,7 +241,7 @@ http {
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_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/host/opkssh-chooser(/.*)?$ {
@@ -376,25 +280,7 @@ http {
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 /session_logs/ {
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 /tailscale/ {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ssh/websocket/ {
@@ -433,9 +319,9 @@ http {
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_set_header X-Forwarded-Port $proxy_x_forwarded_port;
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Host $http_host;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
@@ -453,7 +339,7 @@ http {
proxy_set_header Host $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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /host/tunnel/ {
@@ -462,7 +348,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ssh/tunnel/ {
@@ -473,7 +359,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_buffering off;
@@ -486,7 +372,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /host/file_manager/pinned {
@@ -495,7 +381,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /host/file_manager/shortcuts {
@@ -504,7 +390,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /host/file_manager/sudo-password {
@@ -513,7 +399,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ssh/file_manager/ {
@@ -527,7 +413,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
@@ -548,7 +434,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
@@ -564,7 +450,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /health {
@@ -573,7 +459,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/status(/.*)?$ {
@@ -582,7 +468,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/metrics(/.*)?$ {
@@ -591,7 +477,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
@@ -604,20 +490,7 @@ http {
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 ~ ^/host-metrics(/.*)?$ {
proxy_pass http://127.0.0.1:30005;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/global-settings(/.*)?$ {
@@ -626,7 +499,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/uptime(/.*)?$ {
@@ -635,7 +508,7 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/activity(/.*)?$ {
@@ -644,25 +517,16 @@ http {
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/service-links(/.*)?$ {
location ~ ^/dashboard/preferences(/.*)?$ {
proxy_pass http://127.0.0.1:30006;
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 ~ ^/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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /docker/console/ {
@@ -676,9 +540,9 @@ http {
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_set_header X-Forwarded-Port $proxy_x_forwarded_port;
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Host $http_host;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
@@ -690,55 +554,19 @@ http {
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
}
# --- tmux-monitor begin ---
location ~ ^/tmux_monitor(/.*)?$ {
proxy_pass http://127.0.0.1:30010;
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;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# --- tmux-monitor end ---
location ~ ^/docker(/.*)?$ {
proxy_pass http://127.0.0.1:30007;
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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
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;
+6 -6
View File
@@ -28,8 +28,11 @@
"!dist/icon-mac.png",
"!public/icon-mac.png",
"!dist/icon.ico",
"!public/icon.ico",
"!dist/icon.icns",
"!public/icon.icns"
"!public/icon.icns",
"!dist/icons/**/*",
"!public/icons/**/*"
],
"extraMetadata": {
"main": "electron/main.cjs",
@@ -58,8 +61,6 @@
"artifactName": "termix_windows_${arch}_nsis.${ext}",
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"installerIcon": "public/icon.ico",
"uninstallerIcon": "public/icon.ico",
"shortcutName": "Termix",
"uninstallDisplayName": "Termix"
},
@@ -89,8 +90,8 @@
"desktop": {
"entry": {
"Name": "Termix",
"Comment": "Self-hosted SSH and remote desktop management. ",
"Keywords": "docker;ssh;terminal;telnet;self-hosted;rdp;file-management;vnc;ssh-tunnel;server-stats;termix",
"Comment": "A web-based server management platform",
"Keywords": "terminal;ssh;server;management;",
"StartupWMClass": "termix"
}
}
@@ -129,7 +130,6 @@
"artifactName": "termix_macos_${arch}_dmg.${ext}",
"sign": true
},
"afterPack": "build/after-pack.cjs",
"afterSign": "build/notarize.cjs",
"mas": {
"provisioningProfile": "build/Termix_Mac_App_Store.provisionprofile",
+38 -424
View File
@@ -9,7 +9,6 @@ const {
safeStorage,
Tray,
clipboard,
nativeImage,
} = require("electron");
const path = require("path");
const fs = require("fs");
@@ -18,35 +17,9 @@ const https = require("https");
const http = require("http");
const net = require("net");
const { URL } = require("url");
const { fork, spawn } = require("child_process");
const { fork } = require("child_process");
const WebSocket = require("ws");
// Portable mode: if a `.portable` marker exists next to the executable,
// store all data in a `data` folder beside the exe instead of %APPDATA%.
(function setupPortableDataDir() {
const exeDir = path.dirname(process.execPath);
const markerPath = path.join(exeDir, ".portable");
const envOverride = process.env.TERMIX_DATA_DIR;
let portableDataDir = null;
if (envOverride) {
portableDataDir = envOverride;
} else if (app.isPackaged && fs.existsSync(markerPath)) {
portableDataDir = path.join(exeDir, "data");
}
if (portableDataDir) {
try {
if (!fs.existsSync(portableDataDir)) {
fs.mkdirSync(portableDataDir, { recursive: true });
}
app.setPath("userData", portableDataDir);
} catch {
// Fall back to default userData if we can't create the directory.
}
}
})();
const logFile = path.join(app.getPath("userData"), "termix-main.log");
const electronAuthCookiesPath = path.join(
app.getPath("userData"),
@@ -380,83 +353,16 @@ function isInsecureModeEnabled() {
);
}
function getServerConfigPath() {
return path.join(app.getPath("userData"), "server-config.json");
}
function getServerConfigSync() {
try {
const configPath = getServerConfigPath();
if (!fs.existsSync(configPath)) return null;
return JSON.parse(fs.readFileSync(configPath, "utf8"));
} catch {
return null;
}
}
function getOrigin(url) {
try {
return new URL(url).origin;
} catch {
return null;
}
}
function isPrivateNetworkHost(hostname) {
if (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1"
) {
return true;
}
const ipv4Match = hostname.match(
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
);
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number);
return (
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254)
);
}
if (hostname.startsWith("fc") || hostname.startsWith("fd")) {
return true;
}
return false;
}
function isInvalidCertificateAllowedForUrl(url) {
if (isInsecureModeEnabled()) return true;
try {
const { hostname } = new URL(url);
if (isPrivateNetworkHost(hostname)) return true;
} catch {
// fall through
}
const config = getServerConfigSync();
if (!config?.allowInvalidCertificate || !config?.serverUrl) return false;
return getOrigin(url) === getOrigin(config.serverUrl);
}
function getTlsVerificationOptions(url) {
function getTlsVerificationOptions() {
return {
rejectUnauthorized: !isInvalidCertificateAllowedForUrl(url),
rejectUnauthorized: !isInsecureModeEnabled(),
};
}
function getWebSocketOptions(url, options = {}) {
return {
...options,
...(String(url).startsWith("wss:") ? getTlsVerificationOptions(url) : {}),
...(String(url).startsWith("wss:") ? getTlsVerificationOptions() : {}),
};
}
@@ -470,7 +376,7 @@ function httpFetch(url, options = {}) {
method: options.method || "GET",
headers: options.headers || {},
timeout: options.timeout || 10000,
...(isHttps ? getTlsVerificationOptions(url) : {}),
...(isHttps ? getTlsVerificationOptions() : {}),
};
const req = client.request(url, requestOptions, (res) => {
@@ -503,15 +409,6 @@ if (process.platform === "linux") {
app.commandLine.appendSwitch("--ozone-platform-hint=auto");
app.commandLine.appendSwitch("--enable-features=VaapiVideoDecoder");
// Fix click-coordinate misalignment with fractional display scaling on KDE/Wayland.
// Chromium's hit-testing uses unscaled coords while the compositor scales visually,
// so forcing scale factor 1 keeps them in sync. See: https://github.com/brave/brave-browser/issues/50028
app.commandLine.appendSwitch("--force-device-scale-factor", "1");
}
if (process.platform === "win32") {
app.disableHardwareAcceleration();
}
if (isInsecureModeEnabled()) {
@@ -529,136 +426,15 @@ 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) => {
if (isInvalidCertificateAllowedForUrl(url)) {
event.preventDefault();
logToFile("Allowed invalid certificate for configured server", {
url,
error,
issuer: certificate?.issuerName,
subject: certificate?.subjectName,
});
callback(true);
return;
}
callback(false);
},
);
function getElectronBuildTimestamp() {
try {
const buildInfo = require("./build-info.cjs");
@@ -793,11 +569,9 @@ function getBackendPaths() {
backendCwd: backendDir,
};
}
// 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.
// fork() does not go through Electron's asar redirector — use the unpacked path
const unpackedRoot = appRoot.replace(
/app(-[a-z0-9]+)?\.asar(?!\.unpacked)/,
/app\.asar(?!\.unpacked)/,
"app.asar.unpacked",
);
const backendDir = path.join(unpackedRoot, "dist", "backend", "backend");
@@ -955,13 +729,7 @@ 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(-[a-z0-9]+)?\.asar(?!\.unpacked)/,
"app.asar.unpacked",
),
"public",
);
: path.join(appRoot.replace("app.asar", "app.asar.unpacked"), "public");
let trayIcon;
if (process.platform === "darwin") {
@@ -1031,11 +799,7 @@ function createWindow() {
minWidth: 800,
minHeight: 600,
title: "Termix",
icon: path.join(
appRoot,
"public",
process.platform === "win32" ? "icon.ico" : "icon.png",
),
icon: path.join(appRoot, "public", "icon.png"),
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
@@ -1169,7 +933,6 @@ function createWindow() {
mainWindow.once("ready-to-show", () => {
mainWindow.show();
mainWindow.focus();
});
setTimeout(() => {
@@ -1346,11 +1109,9 @@ ipcMain.handle(
async (_event, authUrl, callbackPort) => {
const http = require("http");
return new Promise((resolve) => {
let timeout;
let settled = false;
return new Promise((resolve, reject) => {
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");
@@ -1361,54 +1122,27 @@ ipcMain.handle(
`<html><body><h2>${success === "true" ? "Authentication successful!" : "Authentication failed."}</h2><p>You can close this tab and return to Termix.</p><script>window.close()</script></body></html>`,
);
server.close();
if (success === "true") {
finish({ success: true, token });
resolve({ success: true, token });
} else {
finish({
resolve({
success: false,
error: error || "Authentication failed",
});
}
return;
}
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not found");
});
const finish = (result) => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
try {
server.close();
} catch {
// Server may not have started yet.
}
resolve(result);
};
const fail = (error) => {
finish({
success: false,
error: error instanceof Error ? error.message : String(error),
});
};
server.once("error", fail);
server.listen(callbackPort, "localhost", async () => {
try {
await shell.openExternal(authUrl);
} catch (error) {
fail(error);
}
server.listen(callbackPort, "127.0.0.1", () => {
shell.openExternal(authUrl);
});
// Timeout after 5 minutes
timeout = setTimeout(
setTimeout(
() => {
fail(new Error("OIDC authentication timed out"));
server.close();
reject(new Error("OIDC authentication timed out"));
},
5 * 60 * 1000,
);
@@ -1418,7 +1152,14 @@ ipcMain.handle(
ipcMain.handle("get-server-config", () => {
try {
return getServerConfigSync();
const userDataPath = app.getPath("userData");
const configPath = path.join(userDataPath, "server-config.json");
if (fs.existsSync(configPath)) {
const configData = fs.readFileSync(configPath, "utf8");
return JSON.parse(configData);
}
return null;
} catch (error) {
console.error("Error reading server config:", error);
return null;
@@ -1428,7 +1169,7 @@ ipcMain.handle("get-server-config", () => {
ipcMain.handle("save-server-config", (event, config) => {
try {
const userDataPath = app.getPath("userData");
const configPath = getServerConfigPath();
const configPath = path.join(userDataPath, "server-config.json");
if (!fs.existsSync(userDataPath)) {
fs.mkdirSync(userDataPath, { recursive: true });
@@ -1577,6 +1318,16 @@ const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
const C2S_WS_LOW_WATERMARK = 256 * 1024;
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
function getServerConfigSync() {
try {
const configPath = path.join(app.getPath("userData"), "server-config.json");
if (!fs.existsSync(configPath)) return null;
return JSON.parse(fs.readFileSync(configPath, "utf8"));
} catch {
return null;
}
}
function getC2SRelayUrl() {
const config = getServerConfigSync();
const serverUrl =
@@ -2644,131 +2395,6 @@ ipcMain.handle("clipboard-write-text", (_event, text) => {
ipcMain.handle("clipboard-read-text", () => clipboard.readText());
ipcMain.handle("show-save-dialog", async (_event, options) => {
return dialog.showSaveDialog(mainWindow, options || {});
});
ipcMain.handle("show-open-dialog", async (_event, options) => {
return dialog.showOpenDialog(mainWindow, options || {});
});
ipcMain.handle("create-temp-file", async (_event, fileData) => {
try {
const result = createManagedTempFile(
fileData?.fileName,
fileData?.content,
fileData?.encoding,
);
return { success: true, ...result };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle("create-temp-folder", async (_event, folderData) => {
try {
const tempId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const tempDir = fs.mkdtempSync(path.join(getTempRoot(), `${tempId}-`));
const folderPath = path.join(
tempDir,
sanitizeFileName(folderData?.folderName || "files"),
);
fs.mkdirSync(folderPath, { recursive: true });
for (const file of folderData?.files || []) {
const relativePath = String(file.relativePath || "")
.split(/[\\/]+/)
.map(sanitizeFileName)
.filter(Boolean)
.join(path.sep);
if (!relativePath) continue;
const targetPath = path.join(folderPath, relativePath);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(
targetPath,
decodeFileContent(file.content, file.encoding),
);
}
tempFiles.set(tempId, { path: folderPath, dir: tempDir });
return { success: true, tempId, path: folderPath };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle("start-drag-to-desktop", (event, dragData) => {
try {
const temp = tempFiles.get(dragData?.tempId);
if (!temp) return { success: false, error: "Temporary file not found" };
event.sender.startDrag({
file: temp.path,
icon: nativeImage.createEmpty(),
});
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle("cleanup-temp-file", (_event, tempId) => {
try {
cleanupManagedTempFile(tempId);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle("open-external-editor", async (_event, fileData) => {
try {
const result = createManagedTempFile(
fileData?.fileName,
fileData?.content,
fileData?.encoding,
);
const editId = result.tempId;
const stat = fs.statSync(result.path);
const watcher = fs.watch(result.path, { persistent: false }, () => {
const session = externalEditorSessions.get(editId);
if (!session) return;
if (session.timer) clearTimeout(session.timer);
session.timer = setTimeout(() => notifyExternalEditorSaved(editId), 500);
});
externalEditorSessions.set(editId, {
path: result.path,
watcher,
timer: null,
lastMtimeMs: stat.mtimeMs,
});
const editorPath =
typeof fileData?.editorPath === "string" && fileData.editorPath.trim()
? fileData.editorPath.trim()
: null;
const openError = await openPathWithEditor(result.path, editorPath);
if (openError) {
closeExternalEditorSession(editId);
return { success: false, error: openError };
}
return { success: true, editId, path: result.path };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle("close-external-editor", (_event, editId) => {
try {
closeExternalEditorSession(editId);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle("test-server-connection", async (event, serverUrl) => {
try {
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
@@ -2949,9 +2575,6 @@ app.whenReady().then(async () => {
"arch:",
process.arch,
);
if (process.platform === "win32") {
app.setAppUserModelId(windowsAppUserModelId);
}
createMenu();
await clearElectronClientCacheIfBuildChanged();
await clearElectronJwtCookiesAtStartup();
@@ -2979,9 +2602,6 @@ app.on("window-all-closed", () => {
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
} else if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
});
@@ -2991,12 +2611,6 @@ app.on("before-quit", () => {
app.on("will-quit", () => {
console.log("App will quit...");
for (const editId of externalEditorSessions.keys()) {
closeExternalEditorSession(editId);
}
for (const tempId of tempFiles.keys()) {
cleanupManagedTempFile(tempId);
}
stopAllC2STunnels();
stopBackendServer();
});
-20
View File
@@ -46,26 +46,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
oidcSystemBrowserAuth: (authUrl, callbackPort) =>
ipcRenderer.invoke("oidc-system-browser-auth", authUrl, callbackPort),
openExternalEditor: (fileData) =>
ipcRenderer.invoke("open-external-editor", fileData),
closeExternalEditor: (editId) =>
ipcRenderer.invoke("close-external-editor", editId),
onExternalEditorSaved: (callback) => {
const listener = (_event, payload) => callback(payload);
ipcRenderer.on("external-editor-saved", listener);
return () => ipcRenderer.removeListener("external-editor-saved", listener);
},
showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options),
showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options),
createTempFile: (fileData) =>
ipcRenderer.invoke("create-temp-file", fileData),
createTempFolder: (folderData) =>
ipcRenderer.invoke("create-temp-folder", folderData),
startDragToDesktop: (dragData) =>
ipcRenderer.invoke("start-drag-to-desktop", dragData),
cleanupTempFile: (tempId) => ipcRenderer.invoke("cleanup-temp-file", tempId),
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
});
+1 -1
View File
@@ -7,7 +7,7 @@ import tseslint from "typescript-eslint";
import { globalIgnores } from "eslint/config";
export default tseslint.config([
globalIgnores(["dist", "release", "Mobile", "src/mcp-server/node_modules"]),
globalIgnores(["dist", "release", "Mobile"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
+1 -1
View File
@@ -1,6 +1,6 @@
[Desktop Entry]
Name=Termix
Comment=Self-hosted SSH and remote desktop management.
Comment=Web-based server management platform with SSH terminal, tunneling, and file editing
Exec=run.sh %U
Icon=com.karmaa.termix
Terminal=false
+2 -2
View File
@@ -5,7 +5,7 @@ Title=Termix - SSH Server Management Platform
IsRuntime=false
Url=https://github.com/Termix-SSH/Termix/releases/download/VERSION_PLACEHOLDER/termix_linux_flatpak.flatpak
RuntimeRepo=https://flathub.org/repo/flathub.flatpakrepo
Comment=Self-hosted SSH and remote desktop management.
Description=Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a multi-platform solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal access, remote desktop control (RDP, VNC, Telnet), SSH tunneling capabilities, remote file management, and many other tools. Termix is the perfect free and self-hosted alternative to Termius available for all platforms.
Comment=Web-based server management platform with SSH terminal, tunneling, and file editing
Description=Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides SSH terminal access, tunneling capabilities, and remote file management.
Icon=https://raw.githubusercontent.com/Termix-SSH/Termix/main/public/icon.png
Homepage=https://github.com/Termix-SSH/Termix
+62 -62
View File
@@ -1,77 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.karmaa.termix</id>
<name>Termix</name>
<summary>Self-hosted SSH and remote desktop management.</summary>
<id>com.karmaa.termix</id>
<name>Termix</name>
<summary>Web-based server management platform with SSH terminal, tunneling, and file editing</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>Apache-2.0</project_license>
<metadata_license>CC0-1.0</metadata_license>
<project_license>Apache-2.0</project_license>
<developer_name>bugattiguy527</developer_name>
<developer_name>bugattiguy527</developer_name>
<description>
<p>
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a
multi-platform solution for managing your servers and infrastructure through a single, intuitive interface.
Termix offers SSH terminal access, remote desktop control (RDP, VNC, Telnet), SSH tunneling capabilities,
remote file management, and many other tools. Termix is the perfect free and self-hosted alternative to
Termius available for all platforms.
</p>
</description>
<description>
<p>
Termix is an open-source, forever-free, self-hosted all-in-one server management platform.
It provides a web-based solution for managing your servers and infrastructure through a single, intuitive interface.
</p>
<p>Features:</p>
<ul>
<li>SSH terminal access with full terminal emulation</li>
<li>SSH tunneling capabilities for secure port forwarding</li>
<li>Remote file management with editor support</li>
<li>Server monitoring and management tools</li>
<li>Self-hosted solution - keep your data private</li>
<li>Modern, intuitive web interface</li>
</ul>
</description>
<launchable type="desktop-id">com.karmaa.termix.desktop</launchable>
<launchable type="desktop-id">com.karmaa.termix.desktop</launchable>
<screenshots>
<screenshot type="default">
<image>https://raw.githubusercontent.com/Termix-SSH/Termix/main/public/screenshots/terminal.png</image>
<caption>SSH Terminal Interface</caption>
</screenshot>
</screenshots>
<screenshots>
<screenshot type="default">
<image>https://raw.githubusercontent.com/Termix-SSH/Termix/main/public/screenshots/terminal.png</image>
<caption>SSH Terminal Interface</caption>
</screenshot>
</screenshots>
<url type="homepage">https://github.com/Termix-SSH/Termix</url>
<url type="bugtracker">https://github.com/Termix-SSH/Support/issues</url>
<url type="help">https://docs.termix.site</url>
<url type="vcs-browser">https://github.com/Termix-SSH/Termix</url>
<url type="homepage">https://github.com/Termix-SSH/Termix</url>
<url type="bugtracker">https://github.com/Termix-SSH/Support/issues</url>
<url type="help">https://docs.termix.site</url>
<url type="vcs-browser">https://github.com/Termix-SSH/Termix</url>
<content_rating type="oars-1.1">
<content_attribute id="social-info">moderate</content_attribute>
</content_rating>
<content_rating type="oars-1.1">
<content_attribute id="social-info">moderate</content_attribute>
</content_rating>
<releases>
<release version="VERSION_PLACEHOLDER" date="DATE_PLACEHOLDER">
<description>
<p>Latest release of Termix</p>
</description>
<url>https://github.com/Termix-SSH/Termix/releases</url>
</release>
</releases>
<releases>
<release version="VERSION_PLACEHOLDER" date="DATE_PLACEHOLDER">
<description>
<p>Latest release of Termix</p>
</description>
<url>https://github.com/Termix-SSH/Termix/releases</url>
</release>
</releases>
<categories>
<category>Development</category>
<category>Network</category>
<category>System</category>
</categories>
<categories>
<category>Development</category>
<category>Network</category>
<category>System</category>
</categories>
<keywords>
<keyword>docker</keyword>
<keyword>ssh</keyword>
<keyword>terminal</keyword>
<keyword>telnet</keyword>
<keyword>self-hosted</keyword>
<keyword>rdp</keyword>
<keyword>file-management</keyword>
<keyword>vnc</keyword>
<keyword>ssh-tunnel</keyword>
<keyword>server-stats</keyword>
<keyword>termix</keyword>
<keywords>
<keyword>ssh</keyword>
<keyword>terminal</keyword>
<keyword>server</keyword>
<keyword>management</keyword>
<keyword>tunnel</keyword>
<keyword>file-manager</keyword>
</keywords>
</keywords>
<provides>
<binary>termix</binary>
</provides>
<provides>
<binary>termix</binary>
</provides>
<requires>
<internet>always</internet>
</requires>
<requires>
<internet>always</internet>
</requires>
</component>
-1
View File
@@ -18,7 +18,6 @@ finish-args:
- --socket=ssh-auth
- --socket=session-bus
- --talk-name=org.freedesktop.secrets
- --talk-name=org.freedesktop.portal.Desktop
- --env=ELECTRON_TRASH=gio
- --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons
- --env=ELECTRON_OZONE_PLATFORM_HINT=auto
+3 -3
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="favicon.ico" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#09090b" />
@@ -12,8 +12,8 @@
content="black-translucent"
/>
<meta name="apple-mobile-web-app-title" content="Termix" />
<link rel="apple-touch-icon" href="icons/512x512.png" />
<link rel="manifest" href="manifest.json" />
<link rel="apple-touch-icon" href="/icons/512x512.png" />
<link rel="manifest" href="/manifest.json" />
<title>Termix</title>
<style>
.hide-scrollbar {
-98
View File
@@ -1,98 +0,0 @@
{
"$schema": "https://unpkg.com/knip@latest/schema.json",
"entry": [
"src/backend/starter.ts",
"src/backend/swagger.ts",
"scripts/**/*.cjs"
],
"project": ["src/**/*.{ts,tsx}", "electron/**/*.cjs", "scripts/**/*.cjs"],
"ignoreBinaries": ["powershell"],
"ignoreDependencies": [
"@codemirror/autocomplete",
"@codemirror/commands",
"@codemirror/search",
"@codemirror/theme-one-dark",
"@codemirror/view",
"@electron/notarize",
"@monaco-editor/react",
"@radix-ui/react-accordion",
"@radix-ui/react-alert-dialog",
"@radix-ui/react-checkbox",
"@radix-ui/react-dialog",
"@radix-ui/react-dropdown-menu",
"@radix-ui/react-label",
"@radix-ui/react-popover",
"@radix-ui/react-progress",
"@radix-ui/react-scroll-area",
"@radix-ui/react-select",
"@radix-ui/react-separator",
"@radix-ui/react-slider",
"@radix-ui/react-slot",
"@radix-ui/react-switch",
"@radix-ui/react-tabs",
"@radix-ui/react-tooltip",
"@types/better-sqlite3",
"@types/cookie-parser",
"@types/cors",
"@types/express",
"@types/guacamole-common-js",
"@types/js-yaml",
"@types/jsonwebtoken",
"@types/multer",
"@types/qrcode",
"@types/speakeasy",
"@types/ssh2",
"@uiw/codemirror-extensions-langs",
"@uiw/codemirror-theme-github",
"@uiw/react-codemirror",
"@xterm/addon-clipboard",
"@xterm/addon-fit",
"@xterm/addon-unicode11",
"@xterm/addon-web-links",
"@xterm/xterm",
"axios",
"bcryptjs",
"better-sqlite3",
"body-parser",
"chalk",
"class-variance-authority",
"clsx",
"cmdk",
"cookie-parser",
"cors",
"cytoscape",
"drizzle-orm",
"express",
"guacamole-lite",
"guacamole-common-js",
"jose",
"js-yaml",
"jsonwebtoken",
"jszip",
"multer",
"nanoid",
"qrcode",
"i18next-browser-languagedetector",
"lucide-react",
"motion",
"radix-ui",
"react-cytoscapejs",
"react-h5-audio-player",
"react-hook-form",
"react-icons",
"react-markdown",
"react-pdf",
"react-photo-view",
"react-syntax-highlighter",
"react-xtermjs",
"remark-gfm",
"socks",
"speakeasy",
"ssh2",
"undici",
"husky",
"sharp",
"sonner",
"tailwind-merge"
]
}
+2282 -4048
View File
File diff suppressed because it is too large Load Diff
+59 -81
View File
@@ -1,8 +1,8 @@
{
"name": "termix",
"private": true,
"version": "2.5.0",
"description": "Self-hosted SSH and remote desktop management.",
"version": "2.3.0",
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
"author": "Karmaa",
"main": "electron/main.cjs",
"engines": {
@@ -12,17 +12,11 @@
"scripts": {
"format": "prettier --write .",
"format:check": "prettier --check .",
"biome:check": "biome check biome.json package.json",
"biome:fix": "biome check --write biome.json package.json",
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-xterm-android-ime.cjs",
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs",
"prebuild": "node scripts/write-electron-build-info.cjs",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"dev": "vite",
"build": "vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\"",
"build:backend": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\"",
@@ -33,7 +27,7 @@
"preview": "vite preview",
"electron:dev": "concurrently \"npm run dev\" \"powershell -c \\\"Start-Sleep -Seconds 5\\\" && electron .\"",
"electron:patch-builder": "node scripts/patch-app-builder-lib.cjs",
"electron:rebuild": "electron-rebuild -f -w better-sqlite3 -w serialport",
"electron:rebuild": "electron-rebuild -f -w better-sqlite3",
"build:win-portable": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --win --dir",
"build:win-installer": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --win --publish=never",
"build:linux-portable": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --linux --dir",
@@ -43,14 +37,11 @@
"build:mac-dev": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --mac dir --publish=never"
},
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"@tanstack/react-virtual": "^3.14.6",
"@types/ldapjs": "^3.0.6",
"axios": "^1.18.1",
"@tanstack/react-virtual": "^3.13.26",
"axios": "^1.15.2",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.11.1",
"body-parser": "^2.3.0",
"better-sqlite3": "^12.9.0",
"body-parser": "^2.2.2",
"chalk": "^5.6.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
@@ -58,61 +49,52 @@
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"guacamole-lite": "^1.2.0",
"https-proxy-agent": "^7.0.6",
"jose": "^6.2.2",
"js-yaml": "^5.2.1",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.3",
"jszip": "^3.10.1",
"ldapjs": "^3.0.7",
"motion": "^12.42.2",
"multer": "^2.2.0",
"nanoid": "^5.1.16",
"motion": "^12.38.0",
"multer": "^2.1.1",
"nanoid": "^5.1.9",
"qrcode": "^1.5.4",
"serialport": "^13.0.0",
"socks": "^2.8.7",
"speakeasy": "^2.0.0",
"ssh2": "^1.17.0",
"undici": "^8.7.0",
"undici": "^7.0.0",
"ws": "^8.20.0"
},
"devDependencies": {
"@biomejs/biome": "2.5.2",
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/search": "^6.7.1",
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
"@codemirror/search": "^6.7.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.5",
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"@codemirror/view": "^6.41.1",
"@commitlint/cli": "^21.0.1",
"@commitlint/config-conventional": "^21.0.1",
"@deadendjs/swagger-jsdoc": "^8.1.2",
"@electron/notarize": "^3.1.1",
"@electron/rebuild": "^4.0.4",
"@eslint/js": "^10.0.1",
"@eslint/js": "^9.0.0",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource/fira-code": "^5.2.7",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/source-code-pro": "^5.2.7",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.2.15",
"@radix-ui/react-alert-dialog": "^1.1.18",
"@radix-ui/react-checkbox": "^1.3.6",
"@radix-ui/react-dialog": "^1.1.18",
"@radix-ui/react-dropdown-menu": "^2.1.19",
"@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-popover": "^1.1.18",
"@radix-ui/react-progress": "^1.1.11",
"@radix-ui/react-scroll-area": "^1.2.13",
"@radix-ui/react-select": "^2.3.2",
"@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slider": "^1.4.2",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.2",
"@radix-ui/react-tabs": "^1.1.16",
"@radix-ui/react-tooltip": "^1.2.11",
"@tailwindcss/vite": "^4.3.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.2.4",
"@types/better-sqlite3": "^7.6.13",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
@@ -121,9 +103,9 @@
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.1.0",
"@types/node": "^26.0.0",
"@types/node": "^25.9.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.17",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/speakeasy": "^2.0.10",
"@types/ssh2": "^1.15.5",
@@ -131,9 +113,7 @@
"@uiw/codemirror-extensions-langs": "^4.25.9",
"@uiw/codemirror-theme-github": "^4.25.9",
"@uiw/react-codemirror": "^4.25.9",
"@vitejs/plugin-react": "^6.0.3",
"@vitest/coverage-v8": "^4.1.9",
"@vitest/ui": "^4.1.9",
"@vitejs/plugin-react": "^6.0.1",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
@@ -142,29 +122,28 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"concurrently": "^10.0.3",
"cytoscape": "^3.34.0",
"electron": "^43.0.0",
"electron-builder": "^26.15.3",
"eslint": "^10.5.0",
"concurrently": "^9.2.1",
"cytoscape": "^3.33.2",
"electron": "^42.2.0",
"electron-builder": "^26.8.1",
"eslint": "^9.0.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^17.5.0",
"guacamole-common-js": "^1.5.0",
"husky": "^9.1.7",
"i18next": "^26.3.4",
"i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.8",
"lucide-react": "^1.20.0",
"prettier": "3.8.4",
"radix-ui": "^1.6.1",
"react": "^19.2.7",
"lint-staged": "^17.0.5",
"lucide-react": "^1.11.0",
"prettier": "3.8.3",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"react-cytoscapejs": "^2.0.0",
"react-dom": "^19.2.7",
"react-dom": "^19.2.5",
"react-h5-audio-player": "^3.10.2",
"react-hook-form": "^7.79.0",
"react-hook-form": "^7.73.1",
"react-i18next": "^17.0.4",
"react-icons": "^5.6.0",
"react-markdown": "^10.1.0",
@@ -173,16 +152,15 @@
"react-syntax-highlighter": "^16.1.1",
"react-xtermjs": "^1.0.10",
"remark-gfm": "^4.0.1",
"sharp": "^0.35.3",
"sharp": "^0.34.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.3",
"typescript-eslint": "^8.61.1",
"vite": "^8.0.16",
"vite-plugin-svgr": "^5.2.0",
"vitest": "^4.1.9"
"typescript-eslint": "^8.59.0",
"vite": "^8.0.13",
"vite-plugin-svgr": "^5.2.0"
},
"lint-staged": {
"*.{ts,tsx}": [
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "Termix",
"short_name": "Termix",
"description": "Self-hosted SSH and remote desktop management.",
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
"theme_color": "#09090b",
"background_color": "#09090b",
"display": "standalone",
+5 -6
View File
@@ -1,11 +1,10 @@
const CACHE_NAME = "termix-static-v2";
const BASE_PATH = "__TERMIX_SW_BASE_PATH__";
const STATIC_ASSETS = [
`${BASE_PATH}/favicon.ico`,
`${BASE_PATH}/icons/48x48.png`,
`${BASE_PATH}/icons/128x128.png`,
`${BASE_PATH}/icons/256x256.png`,
`${BASE_PATH}/icons/512x512.png`,
"/favicon.ico",
"/icons/48x48.png",
"/icons/128x128.png",
"/icons/256x256.png",
"/icons/512x512.png",
];
self.addEventListener("install", (event) => {
File diff suppressed because one or more lines are too long
-548
View File
@@ -1,548 +0,0 @@
# Database Layer Refactor Phase 0 Audit
Status: Draft
Branch: `feature/database-layer-refactor`
Purpose: Establish the current database access inventory, domain map, sensitive field map, and first implementation boundaries before changing runtime persistence.
## 1. Scope
Phase 0 does not change runtime behavior.
It produces the evidence needed for the next implementation phases:
- where database access currently happens
- which modules write directly to the database
- which tables belong to which product domains
- which fields are sensitive
- which direct writes are risky under the current in-memory snapshot model
- which domains should move first into repositories
## 2. Current Evidence
Commands used for the initial audit:
```bash
rg -n "getDb\\(|getSqlite\\(|DatabaseSaveTrigger|SimpleDBOps|db\\.\\$client|\\.prepare\\(" src/backend
rg -n "getDb\\(\\)\\.(insert|update|delete)|await db\\.(insert|update|delete)|db\\.(insert|update|delete)|\\.\\$client\\.prepare\\(\\\"(INSERT|UPDATE|DELETE)|\\.prepare\\(\\\"(INSERT|UPDATE|DELETE)|DatabaseSaveTrigger\\.triggerSave|DatabaseSaveTrigger\\.forceSave" src/backend
rg -n "export const .* = sqliteTable\\(" src/backend/database/db/schema.ts
```
High-level findings:
- Database infrastructure is concentrated in `src/backend/database/db/index.ts`.
- Business database access is spread across route modules, SSH modules, utilities, and auth helpers.
- `SimpleDBOps` is not the only write path.
- There are many direct Drizzle writes and raw SQLite writes.
- Some direct writes manually trigger `DatabaseSaveTrigger`; many write paths do not.
- Schema is SQLite-specific through `sqliteTable` and manual `CREATE TABLE IF NOT EXISTS` / `addColumnIfNotExists`.
## 3. Database Access Hotspots
The most database-heavy files by audit hits:
| File | Approx. hits | Notes |
| ----------------------------------------------------- | -----------: | --------------------------------------------------------------- |
| `src/backend/database/db/index.ts` | 75 | database init, schema creation, ad-hoc migration, snapshot save |
| `src/backend/database/routes/alert-rules-routes.ts` | 64 | alert rules, channels, firings, raw SQL deletes |
| `src/backend/database/routes/users.ts` | 54 | users, settings, OIDC/GitHub settings, admin flows |
| `src/backend/database/database.ts` | 27 | import/export, legacy SQLite handling |
| `src/backend/ssh/host-metrics.ts` | 26 | metrics connection and settings reads |
| `src/backend/database/routes/user-settings-routes.ts` | 16 | user settings |
| `src/backend/dashboard.ts` | 15 | dashboard aggregation reads |
| `src/backend/ssh/docker.ts` | 14 | host/credential reads for Docker SSH |
| `src/backend/ssh/managers/health.ts` | 13 | host health checks |
| `src/backend/ssh/alert-engine.ts` | 13 | alert evaluation and firing state |
| `src/backend/utils/user-crypto.ts` | 12 | settings writes for crypto metadata |
| `src/backend/ssh/host-metrics-settings-routes.ts` | 12 | metrics settings |
| `src/backend/ssh/tmux-monitor.ts` | 11 | tmux session tags |
| `src/backend/guacamole/routes.ts` | 11 | Guacamole config/routes |
| `src/backend/database/routes/host.ts` | 10 | host operations and related rows |
This confirms the refactor must be domain-by-domain. A mechanical replacement of `getDb()` would be noisy and unsafe.
## 4. Direct Write Risk Inventory
Under the current architecture, a direct write is risky when it bypasses `SimpleDBOps` and does not trigger `DatabaseSaveTrigger`.
### 4.1 Direct Writes That Need Repository Ownership
These areas perform direct writes and should move behind repositories/services:
| Area | Representative files | Examples |
| --------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------- |
| users/settings/auth | `routes/users.ts`, `utils/auth-manager.ts`, `utils/user-crypto.ts` | sessions, trusted devices, OIDC settings, registration settings |
| RBAC/sharing | `routes/rbac.ts`, `utils/shared-credential-manager.ts` | roles, host access, snippet access, shared credentials |
| hosts/credentials | `routes/host.ts`, `routes/credentials.ts`, `host-resolver.ts` | host access cleanup, credential usage |
| file manager metadata | `host-file-manager-bookmark-routes.ts` | recent, pinned, shortcuts |
| alerts | `alert-rules-routes.ts`, `ssh/alert-engine.ts` | channels, rules, firings |
| metrics | `host-metrics-preferences-routes.ts`, `managers/health.ts` | preferences, health checks, history |
| terminal logs | `terminal-session-manager.ts` | session recording metadata |
| tmux | `tmux-monitor.ts` | tmux session tags |
| import/export | `database/database.ts` | SQLite import and forced save |
| open tabs | `routes/open-tabs.ts` | tab persistence and cleanup |
| API keys | `user-api-key-routes.ts`, `utils/auth-manager.ts` | API key create/delete/last-used |
| SSO and identity | `sso-provider-routes.ts`, `termix-id.ts` | providers, identity keys/CA |
### 4.2 Immediate Compatibility Rule
Until a domain is migrated to repositories:
- Every direct write must either be moved into a repository or explicitly trigger persistence in the old runtime.
- New code should not add direct `getDb()` writes outside infrastructure or repositories.
- The draft branch should add an enforcement check before Phase 8, not immediately, because the current codebase still violates the target rule widely.
## 5. Table Domain Map
### 5.1 Identity and Authentication
| Tables | Notes |
| ---------------------- | -------------------------------------------- |
| `users` | local/OIDC users, TOTP fields, password hash |
| `sessions` | JWT sessions |
| `trusted_devices` | remembered devices |
| `api_keys` | API token hashes/prefixes |
| `sso_providers` | configured SSO providers |
| `termix_identities` | Termix identity records |
| `termix_identity_keys` | public/private identity key metadata |
| `termix_identity_ca` | CA material, private key is sensitive |
Suggested repository:
- `userRepository`
- `sessionRepository`
- `trustedDeviceRepository`
- `apiKeyRepository`
- `ssoProviderRepository`
- `termixIdentityRepository`
### 5.2 Hosts and Credentials
| Tables | Notes |
| ---------------------- | ------------------------------------------------ |
| `ssh_data` | primary host table, many config JSON/text fields |
| `ssh_credentials` | reusable credentials |
| `ssh_credential_usage` | usage history |
| `ssh_folders` | folder metadata |
| `host_access` | sharing/RBAC access rows |
| `shared_credentials` | encrypted shared credential material |
| `network_topology` | topology graph/config |
Suggested repository:
- `hostRepository`
- `credentialRepository`
- `credentialUsageRepository`
- `hostAccessRepository`
- `sharedCredentialRepository`
- `hostFolderRepository`
- `networkTopologyRepository`
### 5.3 RBAC
| Tables | Notes |
| ---------------- | ------------------------ |
| `roles` | role definitions |
| `user_roles` | user to role assignments |
| `host_access` | host-level grants |
| `snippet_access` | snippet-level grants |
Suggested repository:
- `roleRepository`
- `accessRepository`
### 5.4 File Manager
| Tables | Notes |
| ------------------------ | ---------------------------------- |
| `file_manager_recent` | recently opened paths |
| `file_manager_pinned` | pinned paths |
| `file_manager_shortcuts` | saved shortcuts |
| `transfer_recent` | host-to-host transfer destinations |
Suggested repository:
- `fileManagerRepository`
- `transferRecentRepository`
### 5.5 Snippets
| Tables | Notes |
| ----------------- | ----------------------- |
| `snippets` | command snippets |
| `snippet_folders` | snippet folder metadata |
| `snippet_access` | snippet sharing |
Suggested repository:
- `snippetRepository`
### 5.6 Runtime Metadata and Audit
| Tables | Notes |
| -------------------- | ------------------------------------------------------ |
| `audit_logs` | append-only audit trail |
| `session_recordings` | terminal recording metadata, log content is file-based |
| `recent_activity` | host activity feed |
| `command_history` | terminal command history |
| `user_open_tabs` | UI restore state; currently cleared on startup |
| `user_preferences` | per-user UI/preferences |
| `settings` | global settings |
Suggested repository:
- `auditRepository`
- `sessionRecordingRepository`
- `activityRepository`
- `commandHistoryRepository`
- `openTabsRepository`
- `userPreferencesRepository`
- `settingsRepository`
### 5.7 Metrics and Alerts
| Tables | Notes |
| -------------------------- | -------------------------- |
| `host_metrics_preferences` | metrics layout/preferences |
| `host_health_checks` | health check definitions |
| `host_health_history` | health check results |
| `host_metrics_history` | metrics history |
| `alert_rules` | alert definitions |
| `notification_channels` | webhook/ntfy/etc config |
| `alert_rule_channels` | rule/channel joins |
| `alert_firings` | firing/ack state |
| `dismissed_alerts` | dismissed system alerts |
Suggested repository:
- `metricsRepository`
- `healthCheckRepository`
- `alertRepository`
- `notificationRepository`
### 5.8 Integrations and Feature Config
| Tables | Notes |
| ------------------------- | --------------------------------------- |
| `c2s_tunnel_presets` | tunnel preset config |
| `opkssh_tokens` | OPKSSH cert/private key cache |
| `vault_profiles` | Vault profile config, mostly non-secret |
| `vault_tokens` | Vault cert/private key cache |
| `dashboard_service_links` | dashboard links |
| `homepage_items` | homepage widgets/items |
| `homepage_layouts` | homepage layouts |
| `tmux_session_tags` | tmux tag metadata |
Suggested repository:
- `tunnelPresetRepository`
- `opksshTokenRepository`
- `vaultRepository`
- `dashboardRepository`
- `homepageRepository`
- `tmuxRepository`
## 6. Sensitive Field Map
The current explicit `FieldCrypto` map encrypts:
| Table | Fields |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `users` | `passwordHash`, `clientSecret`, `totpSecret`, `totpBackupCodes`, `oidcIdentifier` |
| `ssh_data` | `password`, `key`, `keyPassword`, `sudoPassword`, `autostartPassword`, `autostartKey`, `autostartKeyPassword`, `socks5Password`, `rdpPassword`, `vncPassword`, `telnetPassword` |
| `ssh_credentials` | `password`, `privateKey`, `keyPassword`, `key`, `publicKey` |
| `opkssh_tokens` | `sshCert`, `privateKey` |
| `termix_identity_ca` | `privateKey` |
| `vault_tokens` | `sshCert`, `privateKey` |
Additional sensitive fields by table semantics:
| Table | Fields / reason |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ssh_credentials` | `systemPassword`, `systemKey`, `systemKeyPassword` are system-key encrypted credential copies |
| `shared_credentials` | `encryptedUsername`, `encryptedAuthType`, `encryptedPassword`, `encryptedKey`, `encryptedKeyPassword`, `encryptedKeyType` are already encrypted payload fields |
| `api_keys` | `tokenHash` is not plaintext but is authentication material; `tokenPrefix` may remain plaintext for display |
| `settings` | some keys may hold provider secrets or reset codes; repository must classify by key |
| `notification_channels` | config may include webhook URLs/tokens; treat config as sensitive unless split |
| `alert_rules` | rule definitions are usually not secret but can include host/resource metadata |
| `homepage_items` | widget config can include URLs/API config; classify per widget type |
| `c2s_tunnel_presets` | config can include connection details; review before plaintext external DB storage |
| `termix_identity_keys` | inspect key material fields before migration; public keys are not secret but private material must never be plaintext |
| `vault_profiles` | current comments say profile fields are non-secret; keep that invariant explicit |
Open privacy decision:
- `ssh_data.ip`, domain fields, usernames, folders, and tags are queryable today.
- Encrypting them improves confidentiality but breaks search/filter/sort unless blind indexes are added.
- Recommended initial migration: keep them plaintext and document privacy implications; add optional privacy mode later.
## 7. Repository Migration Order
Use a vertical slice instead of broad replacement.
### 7.1 First Slice
1. `settingsRepository`
2. `userRepository`
3. `sessionRepository`
4. `hostRepository`
5. `credentialRepository`
Reasons:
- Covers the app's boot/login/core host management path.
- Exercises field encryption.
- Exercises per-user data unlock requirements.
- Exercises transaction and migration behavior.
- Builds reusable patterns for the rest of the backend.
### 7.2 Second Slice
1. `hostAccessRepository`
2. `roleRepository`
3. `sharedCredentialRepository`
4. `auditRepository`
5. `userPreferencesRepository`
Reasons:
- Completes permission and sharing boundaries.
- Removes high-risk direct writes in admin/RBAC flows.
- Moves audit to an append-only repository.
### 7.3 Third Slice
1. `snippetRepository`
2. `fileManagerRepository`
3. `metricsRepository`
4. `alertRepository`
5. `homepageRepository`
Reasons:
- These are broad feature domains with many routes.
- They should reuse patterns from the core slices.
### 7.4 Fourth Slice
1. `vaultRepository`
2. `opksshTokenRepository`
3. `termixIdentityRepository`
4. `tunnelPresetRepository`
5. `tmuxRepository`
Reasons:
- Sensitive token/key cache domains need careful encryption tests.
- Some data is transient and should have retention cleanup.
## 8. Adapter Design Notes
The adapter boundary should expose:
```ts
interface DatabaseAdapter {
dialect: "sqlite" | "postgres" | "mysql";
connect(): Promise<void>;
close(): Promise<void>;
migrate(): Promise<void>;
transaction<T>(fn: (tx: DatabaseTransaction) => Promise<T>): Promise<T>;
}
```
The repository layer should not depend on `better-sqlite3`.
For Drizzle, likely options:
- keep dialect-specific Drizzle clients internally
- expose repositories instead of exposing the raw Drizzle client
- keep schema definitions close to migrations, not route handlers
Important: do not make route modules import dialect-specific schema objects after migration.
## 9. Compatibility Shims
During the migration, avoid a flag day.
Add a compatibility database module that lets old code continue to run while new repositories are introduced:
```text
legacy getDb() path
new adapter/repository path
```
Rules:
- New modules use repositories only.
- Migrated modules must not fall back to `getDb()`.
- Legacy path is removed only after all domains move.
## 10. Phase 1 Entry Criteria
Before implementing the adapter skeleton:
- This audit document exists.
- The sensitive field map is reviewed.
- The first vertical slice is accepted.
- The draft PR remains draft.
- No runtime behavior has changed.
## 11. Phase 1 Deliverables
Recommended first implementation PR on this branch:
- `src/backend/database/runtime/config.ts`
- `src/backend/database/runtime/adapter.ts`
- `src/backend/database/runtime/sqlite-adapter.ts`
- `src/backend/database/repositories/settings-repository.ts`
- `src/backend/database/repositories/user-repository.ts`
- `src/backend/database/repositories/session-repository.ts`
- `src/backend/database/repositories/host-repository.ts`
- `src/backend/database/repositories/credential-repository.ts`
- `src/backend/database/repositories/field-encryption-boundary.ts`
- `src/backend/database/repositories/current-settings-repository.ts`
- tests for config parsing and SQLite adapter boot
Started:
- runtime config parser
- SQLite adapter skeleton
- migration metadata table bootstrap
- `SettingsRepository` skeleton and tests
- `UserRepository` and `SessionRepository` skeletons and tests
- `HostRepository` and `CredentialRepository` skeletons and tests
- `FieldEncryptionBoundary` skeleton and tests
- first settings route slice wired through `SettingsRepository`
- user settings route direct `settings` table access moved behind
`SettingsRepository`
- host metrics settings route direct `settings` table access moved behind
`SettingsRepository`
- ACME SSL settings route direct `settings` table access moved behind
`SettingsRepository`
- terminal route direct `settings` table access moved behind
`SettingsRepository`
- tailscale route direct `settings` table access moved behind
`SettingsRepository`
- Guacamole route and WebSocket server direct `settings` table access moved
behind the current settings repository boundary
- auth token expiry and terminal session timeout settings reads moved behind the
current settings repository boundary
- open tabs, TOTP, and LDAP auth route settings reads moved behind the current
settings repository boundary
- host metrics polling settings reads moved behind the current settings
repository boundary
- backend startup settings reads moved behind the current settings repository
boundary
- user deletion cleanup now removes per-user settings through
`SettingsRepository.deleteLike`
- password reset route reset code and temporary token settings access moved
behind `SettingsRepository`
- OIDC utility legacy config fallback reads `oidc_config` through
`SettingsRepository`
- user route registration/password flags and OIDC config administration moved
behind the current settings repository boundary
- OIDC authorize/callback temporary state and auto-provision reads moved behind
the current settings repository boundary
- user login settings reads moved behind the current settings repository
boundary, completing direct `settings` access cleanup in `routes/users.ts`
- user encryption metadata in `utils/user-crypto.ts` moved behind the current
settings repository boundary
- database startup and schema migration defaults in `database/db/index.ts`
moved to local raw settings helpers
- database import/export settings handling in `database/database.ts` moved to
local helper boundaries
- core `auth-manager.ts` session create/read/update/revoke/list paths started
using the current session repository boundary
- remaining `auth-manager.ts` session cleanup/middleware/logout paths and
`user-session-routes.ts` single-session lookup moved behind the current
session repository boundary
- current user repository factory/write-save hook added, and
`user-admin-routes.ts` list/admin promotion/admin removal/admin-create user
paths moved behind the current user repository boundary
- low-risk `routes/users.ts` current-user lookup and admin gate checks moved
behind the current user repository boundary
- user registration, self-delete, password change hash updates, and admin
delete-user lookup paths in `routes/users.ts` moved behind the current user
repository boundary, with first-user admin creation kept transactional inside
`UserRepository`
- traditional login username lookup and `auth-manager.ts` admin user checks
moved behind the current user repository boundary
- GitHub and standard OIDC callback user lookup/create/rollback/profile/admin
sync writes moved behind the current user repository boundary, removing direct
Drizzle `users` table access from `routes/users.ts`, `user-admin-routes.ts`,
and `auth-manager.ts`
- API key create/list/delete and API key authentication last-used updates moved
behind the current API key repository boundary
- trusted device check/add/remove and TOTP trusted-device cleanup moved behind
the current trusted device repository boundary
- user session routes moved user/admin lookups and admin session username
enrichment behind the current user repository boundary
- vault admin checks, Termix ID audit username lookup, permission manager admin
checks, and user data export user lookup moved behind the current user
repository boundary
- SSH credential OIDC username expansion and tmux monitor audit actor username
lookup moved behind the current user repository boundary
- user settings route admin checks and audit actor username lookups moved behind
the current user repository boundary
- ACME SSL route admin checks and audit actor username lookups moved behind the
current user repository boundary
- audit log route admin checks moved behind the current user repository boundary
- OIDC account link/unlink route user lookups and OIDC field updates moved
behind the current user repository boundary
- password reset route user lookups, password hash updates, and TOTP reset
fields moved behind the current user repository boundary
- user deletion helper now removes sessions through the current session
repository and the final user record through the current user repository
- snippet create/update/delete audit actor username lookups moved behind the
current user repository boundary
- LDAP login existing-user lookup, encryption rollback delete, admin sync, and
display-name sync moved behind the current user repository boundary
- TOTP setup/enable/disable/backup-code/login verification user updates and
session revocation moved behind the current user/session repository
boundaries
- RBAC host sharing, role assignment, and snippet sharing target-user existence
checks moved behind the current user repository boundary, with existing
RBAC/snippet owner username joins retained
- RBAC role list/create/update/delete, user-role assignment/removal/listing,
and shared host/snippet role-id lookups moved behind the current role
repository boundary, with existing RBAC/share credential joins retained
- permission manager role permission aggregation, role-id lookups for shared
host access, and admin role checks moved behind the current role repository
boundary
- RBAC host/snippet access-list read models moved behind the current RBAC access
repository boundary, and snippet route shared-access role-id lookups moved
behind the current role repository boundary
- RBAC shared host/shared snippet read models and the main snippet
shared-snippet read model moved behind the current RBAC access repository
boundary
- RBAC host/snippet access grant, revoke, and direct host-access credential
override writes moved behind the current RBAC access repository boundary,
with shared credential material creation retained in the existing manager
- permission manager host-access expiration cleanup, shared host-access lookup,
and last-access timestamp updates moved behind the current RBAC access
repository boundary
- repository rollout guard added through `DATABASE_LAYER_REPOSITORY_ROLLOUT`
for the migrated settings/users/sessions/API-key/trusted-device/role/RBAC-access
slice
Keep it small. Do not wire host or credential routes into the new repositories in
the same first implementation commit.
## 12. Current Unknowns
- Exact Drizzle multi-dialect strategy needs a spike.
- The project may need a migration generator or a custom migration runner.
- Some raw SQL in `routes/users.ts`, `alert-rules-routes.ts`, and `db/index.ts` must be rewritten or isolated.
- `settings` contains mixed public and sensitive values; key-level classification is required.
- `homepage_items.config`, `notification_channels.config`, and tunnel configs may contain embedded secrets.
- Legacy encrypted snapshot fixtures need to be created before migration implementation.
## 13. Decision Log
- Default upgrade target should be persistent SQLite, not PostgreSQL/MySQL.
- PostgreSQL/MySQL migration should be explicit and initially manual/experimental.
- Runtime SSH/WebSocket/tunnel state remains memory-only.
- Field-level encryption is mandatory for every database backend.
- Field-level encryption must use a stable record id; temporary encryption
contexts are forbidden for newly written repository data.
- Repository migration should start with settings/users/sessions/hosts/credentials.
File diff suppressed because it is too large Load Diff
+8 -56
View File
@@ -28,17 +28,10 @@
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
</p>
<br />
Termix مجاني ومفتوح المصدر. إذا وجدته مفيدًا، فكّر في [التبرع](https://donate.termix.site/) للمساعدة في تغطية تكاليف الخادم ووقت التطوير.
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
<br />
@@ -94,65 +87,37 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا
<tr>
<td width="50%" valign="top">
**إدارة Docker و Podman:**
تشغيل وإيقاف وتعليق وحذف الحاويات. عرض إحصائيات الحاويات. التحكم في الحاوية باستخدام طرفية docker exec. يدعم كلاً من Docker و Podman كبيئة تشغيل للحاويات. لم يُصمم ليحل محل Portainer أو Dockge بل لإدارة حاوياتك ببساطة مقارنة بإنشائها.
**إدارة Docker:**
تشغيل وإيقاف وتعليق وحذف الحاويات. عرض إحصائيات الحاويات. التحكم في الحاوية باستخدام طرفية docker exec. لم يُصمم ليحل محل Portainer أو Dockge بل لإدارة حاوياتك ببساطة مقارنة بإنشائها.
</td>
<td width="50%" valign="top">
**مدير مضيفات SSH:**
حفظ وتنظيم وإدارة اتصالات SSH الخاصة بك باستخدام العلامات والمجلدات (مع دعم تخصيص المجلدات والمجلدات المتداخلة)، وحفظ بيانات تسجيل الدخول القابلة لإعادة الاستخدام بسهولة مع إمكانية أتمتة نشر مفاتيح SSH.
حفظ وتنظيم وإدارة اتصالات SSH الخاصة بك باستخدام العلامات والمجلدات، وحفظ بيانات تسجيل الدخول القابلة لإعادة الاستخدام بسهولة مع إمكانية أتمتة نشر مفاتيح SSH.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**مقاييس المضيف:**
عرض استخدام المعالج والذاكرة والقرص والشبكة ووقت التشغيل ومعلومات النظام وجدار الحماية ومراقب المنافذ وعارض السجلات والمستخدمين/الصلاحيات والشهادات وغيرها الكثير، تعمل على معظم الخوادم المبنية على Linux.
**إحصائيات الخادم:**
عرض استخدام المعالج والذاكرة والقرص إلى جانب الشبكة ووقت التشغيل ومعلومات النظام وجدار الحماية ومراقب المنافذ على معظم الخوادم المبنية على Linux.
</td>
<td width="50%" valign="top">
**مصادقة المستخدمين:**
إدارة آمنة للمستخدمين مع ضوابط إدارية ودعم OIDC/LDAP/SSO (مع التحكم في الوصول) و 2FA (TOTP). عرض جلسات المستخدمين النشطة عبر جميع المنصات وإلغاء الصلاحيات. ربط حسابات OIDC/المحلية معاً. عرض سجل تدقيق لجميع إجراءات المستخدمين.
إدارة آمنة للمستخدمين مع ضوابط إدارية ودعم OIDC (مع التحكم في الوصول) و 2FA (TOTP). عرض جلسات المستخدمين النشطة عبر جميع المنصات وإلغاء الصلاحيات. ربط حسابات OIDC/المحلية معاً.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**تكامل Tailscale:**
عرض أجهزة شبكتك من Tailscale لإضافتها بسرعة كمضيفات، والاتصال عبر Tailscale SSH كطريقة مصادقة، مما يتيح لقوائم تحكم الوصول في Tailscale التعامل مع التفويض دون الحاجة لتخزين بيانات اعتماد.
</td>
<td width="50%" valign="top">
**RBAC:**
إنشاء الأدوار ومشاركة المضيفات عبر المستخدمين/الأدوار.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**الاتصالات التسلسلية:**
الاتصال بالأجهزة التسلسلية (أجهزة التوجيه والمفاتيح والمتحكمات الدقيقة وغيرها) مباشرة من المتصفح أو تطبيق سطح المكتب. ضبط معدل نقل البيانات وبتات البيانات وبتات التوقف والتكافؤ. يستخدم Web Serial API في المتصفحات المدعومة أو خلفية أصلية في تطبيق Electron.
</td>
<td width="50%" valign="top">
**التنبيهات:**
ضبط قواعد تنبيه قائمة على الحدود لمقاييس المضيف (المعالج والذاكرة والقرص وغيرها) والحصول على إشعارات عبر ntfy أو webhooks عند إطلاقها. عرض التنبيهات النشطة والمحلولة في سجل التاريخ.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**الصفحة الرئيسية:**
صفحة رئيسية قابلة للتخصيص بالكامل مع شبكة أدوات قابلة للسحب والإفلات. أضف أدوات لحالة المضيف وروابط الخدمات والساعات والملاحظات وخلاصات RSS والطقس وحاويات Docker ومخططات مقاييس المضيف والطرفيات المضمنة والإطارات المضمنة وأكثر.
</td>
<td width="50%" valign="top">
@@ -205,8 +170,7 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا
- **سجل الأوامر** - الإكمال التلقائي وعرض أوامر SSH التي تم تنفيذها سابقاً
- **الاتصال السريع** - الاتصال بخادم دون الحاجة إلى حفظ بيانات الاتصال
- **لوحة الأوامر** - اضغط مرتين على Shift الأيسر للوصول السريع إلى اتصالات SSH باستخدام لوحة المفاتيح
- **تكامل Proxmox** - إضافة المضيفات تلقائياً إلى Termix من نسخة Proxmox الخاصة بك
- **ميزات SSH الغنية** - دعم مضيفات القفز، Warpgate، الاتصالات المبنية على TOTP، SOCKS5، التحقق من مفتاح المضيف، الملء التلقائي لكلمة المرور، [OPKSSH](https://github.com/openpubkey/opkssh)، tmux، port knocking، تسجيل الطرفية، إلخ.
- **ميزات SSH الغنية** - دعم مضيفات القفز، Warpgate، الاتصالات المبنية على TOTP، SOCKS5، التحقق من مفتاح المضيف، الملء التلقائي لكلمة المرور، [OPKSSH](https://github.com/openpubkey/opkssh)، tmux، port knocking، إلخ.
</details>
@@ -288,14 +252,6 @@ networks:
<br />
## التبرع
Termix مجاني ومفتوح المصدر. إذا وجدته مفيدًا، فكّر في [التبرع](https://donate.termix.site/) للمساعدة في تغطية تكاليف الخادم ووقت التطوير.
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
## لقطات الشاشة
<div align="center">
@@ -338,10 +294,6 @@ Termix مجاني ومفتوح المصدر. إذا وجدته مفيدًا، ف
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
</tr>
<tr>
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
</tr>
</table>
<sub>قد تكون بعض مقاطع الفيديو والصور قديمة أو قد لا تعرض الميزات بشكل مثالي.</sub>
@@ -352,7 +304,7 @@ Termix مجاني ومفتوح المصدر. إذا وجدته مفيدًا، ف
## الميزات المخططة
راجع [المشاريع](https://github.com/orgs/Termix-SSH/projects/5) لعرض جميع الميزات المخططة. إذا كنت تتطلع للمساهمة، راجع [المساهمة](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
راجع [المشاريع](https://github.com/orgs/Termix-SSH/projects/2) لعرض جميع الميزات المخططة. إذا كنت تتطلع للمساهمة، راجع [المساهمة](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
<br />
+8 -56
View File
@@ -28,17 +28,10 @@
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
</p>
<br />
Termix 免费且开源。如果您觉得它有用,请考虑[捐赠](https://donate.termix.site/)以帮助支付服务器费用和开发时间。
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
<br />
@@ -94,65 +87,37 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
<tr>
<td width="50%" valign="top">
**Docker 和 Podman 管理:**
启动、停止、暂停、移除容器。查看容器统计信息。通过 docker exec 终端控制容器。同时支持 Docker 和 Podman 作为容器运行时。它的初衷不是取代 Portainer 或 Dockge,而是为了比直接创建容器更简单地管理它们。
**Docker 管理:**
启动、停止、暂停、移除容器。查看容器统计信息。通过 docker exec 终端控制容器。它的初衷不是取代 Portainer 或 Dockge,而是为了比直接创建容器更简单地管理它们。
</td>
<td width="50%" valign="top">
**SSH 主机管理器:**
通过标签和文件夹(支持文件夹自定义和嵌套文件夹)保存、组织和管理您的 SSH 连接,轻松保存可重用的登录信息,并能自动化部署 SSH 密钥。
通过标签和文件夹保存、组织和管理您的 SSH 连接,轻松保存可重用的登录信息,并能自动化部署 SSH 密钥。
</td>
</tr>
<tr>
<td width="50%" valign="top">
**主机指标:**
在大多数基于 Linux 的服务器上查看 CPU、内存、磁盘使用情况网络、运行时间、系统信息、防火墙端口监控、日志查看器、用户/权限、证书等更多信息
**服务器统计:**
在大多数基于 Linux 的服务器上查看 CPU、内存、磁盘使用情况以及网络、运行时间、系统信息、防火墙端口监控。
</td>
<td width="50%" valign="top">
**用户认证:**
安全的用户管理,具有管理员控制、OIDC/LDAP/SSO(带访问控制)和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地账户链接在一起。查看所有用户操作的审计日志。
安全的用户管理,具有管理员控制、OIDC(带访问控制)和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地账户链接在一起。
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Tailscale 集成:**
列出您 Tailscale 网络中的设备以快速添加为主机,并使用 Tailscale SSH 作为身份验证方式,让您的 Tailscale ACL 处理授权而无需存储凭据。
</td>
<td width="50%" valign="top">
**RBAC:**
创建角色并在用户/角色之间共享主机。
</td>
</tr>
<tr>
<td width="50%" valign="top">
**串口连接:**
直接从浏览器或桌面应用连接到串口设备(路由器、交换机、微控制器等)。配置波特率、数据位、停止位和奇偶校验。在支持的浏览器中使用 Web Serial API,或在 Electron 应用中使用原生后端。
</td>
<td width="50%" valign="top">
**告警:**
为主机指标(CPU、内存、磁盘等)设置基于阈值的告警规则,并通过 ntfy 或 webhook 接收触发通知。在历史日志中查看触发和已解决的告警。
</td>
</tr>
<tr>
<td width="50%" valign="top">
**主页:**
具有拖放小组件网格的完全可定制主页。添加主机状态、服务链接、时钟、笔记、RSS 订阅、天气、Docker 容器、主机指标图表、嵌入式终端、iframe 等小组件。
</td>
<td width="50%" valign="top">
@@ -205,8 +170,7 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
- **命令历史** - 自动完成并查看之前运行过的 SSH 命令
- **快速连接** - 无需保存连接数据即可连接到服务器
- **命令面板** - 双击左 Shift 键即可通过键盘快速访问 SSH 连接
- **Proxmox 集成** - 从您的 Proxmox 实例自动将主机添加到 Termix
- **丰富的 SSH 功能** - 支持跳转主机、Warpgate、基于 TOTP 的连接、SOCKS5、主机密钥验证、密码自动填充、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、端口敲击、终端日志记录等
- **丰富的 SSH 功能** - 支持跳转主机、Warpgate、基于 TOTP 的连接、SOCKS5、主机密钥验证、密码自动填充、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、端口敲击等
</details>
@@ -288,14 +252,6 @@ networks:
<br />
## 捐赠
Termix 免费且开源。如果您觉得它有用,请考虑[捐赠](https://donate.termix.site/)以帮助支付服务器费用和开发时间。
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
## 展示
<div align="center">
@@ -338,10 +294,6 @@ Termix 免费且开源。如果您觉得它有用,请考虑[捐赠](https://do
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
</tr>
<tr>
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
</tr>
</table>
<sub>某些视频和图像可能已过时,或者可能无法完美展示功能。</sub>
@@ -352,7 +304,7 @@ Termix 免费且开源。如果您觉得它有用,请考虑[捐赠](https://do
## 计划功能
查看 [Projects](https://github.com/orgs/Termix-SSH/projects/5) 了解所有计划功能。如果您想贡献代码,请参阅 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)。
查看 [Projects](https://github.com/orgs/Termix-SSH/projects/2) 了解所有计划功能。如果您想贡献代码,请参阅 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)。
<br />
+8 -56
View File
@@ -28,17 +28,10 @@
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
</p>
<br />
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.
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
<br />
@@ -94,65 +87,37 @@ Verwalten Sie Dateien direkt auf Remote-Servern mit Unterstutzung fur das Anzeig
<tr>
<td width="50%" valign="top">
**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.
**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.
</td>
<td width="50%" valign="top">
**SSH-Host-Manager:**
Speichern, organisieren und verwalten Sie Ihre SSH-Verbindungen mit Tags und Ordnern (Ordneranpassung und verschachtelte Ordner werden unterstutzt) und speichern Sie einfach wiederverwendbare Anmeldeinformationen mit der Moglichkeit, die Bereitstellung von SSH-Schlusseln zu automatisieren.
Speichern, organisieren und verwalten Sie Ihre SSH-Verbindungen mit Tags und Ordnern und speichern Sie einfach wiederverwendbare Anmeldeinformationen mit der Moglichkeit, die Bereitstellung von SSH-Schlusseln zu automatisieren.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Host-Metriken:**
CPU-, Arbeitsspeicher- und Festplattenauslastung, Netzwerk, Betriebszeit, Systeminformationen, Firewall, Port-Monitor, Log-Viewer, Benutzer/Berechtigungen, Zertifikate und vieles mehr auf den meisten Linux-basierten Servern anzeigen.
**Serverstatistiken:**
CPU-, Arbeitsspeicher- und Festplattenauslastung sowie Netzwerk, Betriebszeit, Systeminformationen, Firewall, Port-Monitor auf den meisten Linux-basierten Servern anzeigen.
</td>
<td width="50%" valign="top">
**Benutzerauthentifizierung:**
Sichere Benutzerverwaltung mit Admin-Kontrollen und OIDC-/LDAP-/SSO-Unterstutzung (mit Zugriffskontrolle) sowie 2FA (TOTP)-Unterstutzung. Aktive Benutzersitzungen uber alle Plattformen anzeigen und Berechtigungen widerrufen. OIDC-/Lokale Konten miteinander verknupfen. Audit-Protokoll aller Benutzeraktionen anzeigen.
Sichere Benutzerverwaltung mit Admin-Kontrollen und OIDC- (mit Zugriffskontrolle) sowie 2FA (TOTP)-Unterstutzung. Aktive Benutzersitzungen uber alle Plattformen anzeigen und Berechtigungen widerrufen. OIDC-/Lokale Konten miteinander verknupfen.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
**RBAC:**
Rollen erstellen und Hosts uber Benutzer/Rollen teilen.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
**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.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
@@ -205,8 +170,7 @@ Integrierte Unterstutzung fur ca. 30 Sprachen (verwaltet uber [Crowdin](https://
- **Befehlsverlauf** - Autovervollstandigung und Anzeige zuvor ausgefuhrter SSH-Befehle
- **Schnellverbindung** - Verbinden Sie sich mit einem Server, ohne die Verbindungsdaten speichern zu mussen
- **Befehlspalette** - Doppeltippen Sie die linke Umschalttaste, um schnell auf SSH-Verbindungen mit Ihrer Tastatur zuzugreifen
- **Proxmox-Integration** - Automatisches Hinzufugen von Hosts zu Termix aus Ihrer Proxmox-Instanz
- **SSH-Funktionsreich** - Unterstutzt Jump-Hosts, Warpgate, TOTP-basierte Verbindungen, SOCKS5, Host-Key-Verifizierung, automatisches Ausfullen von Passwortern, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, Port Knocking, Terminal-Protokollierung usw.
- **SSH-Funktionsreich** - Unterstutzt Jump-Hosts, Warpgate, TOTP-basierte Verbindungen, SOCKS5, Host-Key-Verifizierung, automatisches Ausfullen von Passwortern, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, Port Knocking usw.
</details>
@@ -288,14 +252,6 @@ networks:
<br />
## Spenden
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.
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
## Screenshots
<div align="center">
@@ -338,10 +294,6 @@ Termix ist kostenlos und Open Source. Wenn Sie es nützlich finden, erwägen Sie
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
</tr>
<tr>
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
</tr>
</table>
<sub>Einige Videos und Bilder konnen veraltet sein oder Funktionen moglicherweise nicht perfekt darstellen.</sub>
@@ -352,7 +304,7 @@ Termix ist kostenlos und Open Source. Wenn Sie es nützlich finden, erwägen Sie
## Geplante Funktionen
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).
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).
<br />
+8 -56
View File
@@ -28,17 +28,10 @@
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
</p>
<br />
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.
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
<br />
@@ -94,65 +87,37 @@ Gestione archivos directamente en servidores remotos con soporte para visualizar
<tr>
<td width="50%" valign="top">
**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.
**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.
</td>
<td width="50%" valign="top">
**Gestor de Hosts SSH:**
Guarde, organice y gestione sus conexiones SSH con etiquetas y carpetas (con personalizacion de carpetas y soporte de carpetas anidadas), y guarde facilmente informacion de inicio de sesion reutilizable con la capacidad de automatizar el despliegue de claves SSH.
Guarde, organice y gestione sus conexiones SSH con etiquetas y carpetas, y guarde facilmente informacion de inicio de sesion reutilizable con la capacidad de automatizar el despliegue de claves SSH.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Metricas del Host:**
Vea el uso de CPU, memoria y disco, red, tiempo de actividad, informacion del sistema, firewall, monitor de puertos, visor de registros, usuarios/permisos, certificados y muchos mas en la mayoria de los servidores basados en Linux.
**Estadisticas del Servidor:**
Vea el uso de CPU, memoria y disco junto con red, tiempo de actividad, informacion del sistema, firewall, monitor de puertos en la mayoria de los servidores basados en Linux.
</td>
<td width="50%" valign="top">
**Autenticacion de Usuarios:**
Gestion segura de usuarios con controles de administrador y soporte para OIDC/LDAP/SSO (con control de acceso) y 2FA (TOTP). Vea sesiones activas de usuarios en todas las plataformas y revoque permisos. Vincule sus cuentas OIDC/Locales entre si. Vea el registro de auditoria de las acciones de todos los usuarios.
Gestion segura de usuarios con controles de administrador y soporte para OIDC (con control de acceso) y 2FA (TOTP). Vea sesiones activas de usuarios en todas las plataformas y revoque permisos. Vincule sus cuentas OIDC/Locales entre si.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
**RBAC:**
Cree roles y comparta hosts entre usuarios/roles.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
**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.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
@@ -205,8 +170,7 @@ Soporte integrado para aproximadamente 30 idiomas (gestionado por [Crowdin](http
- **Historial de Comandos** - Autocompletado y visualizacion de comandos SSH ejecutados anteriormente
- **Conexion Rapida** - Conectese a un servidor sin necesidad de guardar los datos de conexion
- **Paleta de Comandos** - Pulse dos veces la tecla Shift izquierda para acceder rapidamente a las conexiones SSH con su teclado
- **Integracion con Proxmox** - Agregue automaticamente hosts a Termix desde su instancia de Proxmox
- **SSH Rico en Funciones** - Soporta jump hosts, Warpgate, conexiones basadas en TOTP, SOCKS5, verificacion de clave de host, autocompletado de contrasenas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, registro de terminal, etc.
- **SSH Rico en Funciones** - Soporta jump hosts, Warpgate, conexiones basadas en TOTP, SOCKS5, verificacion de clave de host, autocompletado de contrasenas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc.
</details>
@@ -288,14 +252,6 @@ networks:
<br />
## Donar
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.
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
## Capturas de Pantalla
<div align="center">
@@ -338,10 +294,6 @@ Termix es gratuito y de código abierto. Si lo encuentras útil, considera [dona
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
</tr>
<tr>
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
</tr>
</table>
<sub>Algunos videos e imagenes pueden estar desactualizados o no mostrar perfectamente las caracteristicas.</sub>
@@ -352,7 +304,7 @@ Termix es gratuito y de código abierto. Si lo encuentras útil, considera [dona
## Caracteristicas Planeadas
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).
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).
<br />
+8 -56
View File
@@ -28,17 +28,10 @@
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
</p>
<br />
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.
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
<br />
@@ -94,65 +87,37 @@ Gerez les fichiers directement sur les serveurs distants avec support de la visu
<tr>
<td width="50%" valign="top">
**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.
**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.
</td>
<td width="50%" valign="top">
**Gestionnaire d'hotes SSH:**
Enregistrez, organisez et gerez vos connexions SSH avec des tags et des dossiers (personnalisation des dossiers et prise en charge des dossiers imbriques), et sauvegardez facilement les informations de connexion reutilisables tout en automatisant le deploiement des cles SSH.
Enregistrez, organisez et gerez vos connexions SSH avec des tags et des dossiers, et sauvegardez facilement les informations de connexion reutilisables tout en automatisant le deploiement des cles SSH.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Metriques d'hote:**
Visualisez l'utilisation du CPU, de la memoire, du disque, le reseau, le temps de fonctionnement, les informations systeme, le pare-feu, le moniteur de ports, le visualiseur de journaux, les utilisateurs/permissions, les certificats et bien plus encore sur la plupart des serveurs Linux.
**Statistiques serveur:**
Visualisez l'utilisation du CPU, de la memoire et du disque ainsi que le reseau, le temps de fonctionnement, les informations systeme, le pare-feu et le moniteur de ports sur la plupart des serveurs Linux.
</td>
<td width="50%" valign="top">
**Authentification des utilisateurs:**
Gestion securisee des utilisateurs avec controles administrateur et support OIDC/LDAP/SSO (avec controle d'acces) et 2FA (TOTP). Visualisez les sessions utilisateur actives sur toutes les plateformes et revoquez les permissions. Liez vos comptes OIDC/locaux ensemble. Consultez le journal d'audit des actions de tous les utilisateurs.
Gestion securisee des utilisateurs avec controles administrateur et support OIDC (avec controle d'acces) et 2FA (TOTP). Visualisez les sessions utilisateur actives sur toutes les plateformes et revoquez les permissions. Liez vos comptes OIDC/locaux ensemble.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
**RBAC:**
Creez des roles et partagez des hotes entre utilisateurs/roles.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
**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.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
@@ -205,8 +170,7 @@ Support integre d'environ 30 langues (gere par [Crowdin](https://docs.termix.sit
- **Historique des commandes** - Auto-completion et consultation des commandes SSH precedemment executees
- **Connexion rapide** - Connectez-vous a un serveur sans avoir a sauvegarder les donnees de connexion
- **Palette de commandes** - Appuyez deux fois sur Shift gauche pour acceder rapidement aux connexions SSH avec votre clavier
- **Integration Proxmox** - Ajoutez automatiquement des hotes dans Termix depuis votre instance Proxmox
- **SSH riche en fonctionnalites** - Support des hotes de rebond, Warpgate, connexions basees sur TOTP, SOCKS5, verification des cles d'hote, remplissage automatique des mots de passe, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, journalisation du terminal, etc.
- **SSH riche en fonctionnalites** - Support des hotes de rebond, Warpgate, connexions basees sur TOTP, SOCKS5, verification des cles d'hote, remplissage automatique des mots de passe, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc.
</details>
@@ -288,14 +252,6 @@ networks:
<br />
## Faire un don
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.
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
## Captures d'ecran
<div align="center">
@@ -338,10 +294,6 @@ Termix est gratuit et open source. Si vous le trouvez utile, pensez à [faire un
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
</tr>
<tr>
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
</tr>
</table>
<sub>Certaines videos et images peuvent etre obsoletes ou ne pas presenter parfaitement les fonctionnalites.</sub>
@@ -352,7 +304,7 @@ Termix est gratuit et open source. Si vous le trouvez utile, pensez à [faire un
## Fonctionnalites prevues
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).
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).
<br />
+8 -56
View File
@@ -28,17 +28,10 @@
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
</p>
<br />
Termix मुफ़्त और ओपन सोर्स है। यदि आपको यह उपयोगी लगता है, तो सर्वर लागत और विकास समय में मदद के लिए [दान करें](https://donate.termix.site/)।
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
<br />
@@ -94,65 +87,37 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
<tr>
<td width="50%" valign="top">
**Docker और Podman प्रबंधन:**
कंटेनर शुरू, बंद, पॉज़, हटाएँ। कंटेनर स्टैट्स देखें। docker exec टर्मिनल का उपयोग करके कंटेनर को नियंत्रित करें। Docker और Podman दोनों को कंटेनर रनटाइम के रूप में सपोर्ट करता है। इसे Portainer या Dockge की जगह लेने के लिए नहीं बनाया गया बल्कि कंटेनर बनाने की तुलना में उन्हें सरलता से प्रबंधित करने के लिए बनाया गया है।
**Docker प्रबंधन:**
कंटेनर शुरू, बंद, पॉज़, हटाएँ। कंटेनर स्टैट्स देखें। docker exec टर्मिनल का उपयोग करके कंटेनर को नियंत्रित करें। इसे Portainer या Dockge की जगह लेने के लिए नहीं बनाया गया बल्कि कंटेनर बनाने की तुलना में उन्हें सरलता से प्रबंधित करने के लिए बनाया गया है।
</td>
<td width="50%" valign="top">
**SSH होस्ट मैनेजर:**
टैग और फ़ोल्डर (फ़ोल्डर कस्टमाइज़ेशन और नेस्टेड फ़ोल्डर सपोर्ट के साथ) के साथ अपने SSH कनेक्शन सहेजें, व्यवस्थित करें और प्रबंधित करें, और SSH कुंजियों की तैनाती को स्वचालित करने की क्षमता के साथ पुन: उपयोग योग्य लॉगिन जानकारी आसानी से सहेजें।
टैग और फ़ोल्डर के साथ अपने SSH कनेक्शन सहेजें, व्यवस्थित करें और प्रबंधित करें, और SSH कुंजियों की तैनाती को स्वचालित करने की क्षमता के साथ पुन: उपयोग योग्य लॉगिन जानकारी आसानी से सहेजें।
</td>
</tr>
<tr>
<td width="50%" valign="top">
**होस्ट मेट्रिक्स:**
अधिकांश Linux आधारित सर्वर पर CPU, मेमोरी, डिस्क उपयोग, नेटवर्क, अपटाइम, सिस्टम जानकारी, फ़ायरवॉल, पोर्ट मॉनिटर, लॉग व्यूअर, उपयोगकर्ता/अनुमतियाँ, सर्टिफ़िकेट और भी बहुत कुछ देखें।
**सर्वर आँकड़े:**
अधिकांश Linux आधारित सर्वर पर नेटवर्क, अपटाइम, सिस्टम जानकारी, फ़ायरवॉल, पोर्ट मॉनिटर के साथ CPU, मेमोरी और डिस्क उपयोग देखें।
</td>
<td width="50%" valign="top">
**उपयोगकर्ता प्रमाणीकरण:**
व्यवस्थापक नियंत्रण और OIDC/LDAP/SSO (एक्सेस कंट्रोल के साथ) और 2FA (TOTP) सपोर्ट के साथ सुरक्षित उपयोगकर्ता प्रबंधन। सभी प्लेटफ़ॉर्म पर सक्रिय उपयोगकर्ता सत्र देखें और अनुमतियाँ रद्द करें। अपने OIDC/स्थानीय खातों को एक साथ जोड़ें। सभी उपयोगकर्ताओं की कार्रवाइयों का ऑडिट लॉग देखें।
व्यवस्थापक नियंत्रण और OIDC (एक्सेस कंट्रोल के साथ) और 2FA (TOTP) सपोर्ट के साथ सुरक्षित उपयोगकर्ता प्रबंधन। सभी प्लेटफ़ॉर्म पर सक्रिय उपयोगकर्ता सत्र देखें और अनुमतियाँ रद्द करें। अपने OIDC/स्थानीय खातों को एक साथ जोड़ें।
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Tailscale एकीकरण:**
अपने Tailscale नेटवर्क के डिवाइस सूचीबद्ध करें ताकि उन्हें जल्दी से होस्ट के रूप में जोड़ा जा सके, और Tailscale SSH को प्रमाणीकरण विधि के रूप में उपयोग करके कनेक्ट करें, जिससे आपके Tailscale ACL क्रेडेंशियल संग्रहीत किए बिना प्राधिकरण संभाल सकें।
</td>
<td width="50%" valign="top">
**RBAC:**
भूमिकाएँ बनाएँ और उपयोगकर्ताओं/भूमिकाओं में होस्ट साझा करें।
</td>
</tr>
<tr>
<td width="50%" valign="top">
**सीरियल कनेक्शन:**
सीरियल डिवाइस (राउटर, स्विच, माइक्रोकंट्रोलर आदि) से सीधे ब्राउज़र या डेस्कटॉप ऐप से कनेक्ट करें। बॉड रेट, डेटा बिट्स, स्टॉप बिट्स और पैरिटी कॉन्फ़िगर करें। समर्थित ब्राउज़र में Web Serial API या Electron ऐप में नेटिव बैकएंड का उपयोग करता है।
</td>
<td width="50%" valign="top">
**अलर्ट:**
होस्ट मेट्रिक्स (CPU, मेमोरी, डिस्क आदि) पर थ्रेशोल्ड-आधारित अलर्ट नियम सेट करें और जब वे ट्रिगर हों तो ntfy या webhooks के माध्यम से सूचना पाएँ। इतिहास लॉग में सक्रिय और हल किए गए अलर्ट देखें।
</td>
</tr>
<tr>
<td width="50%" valign="top">
**होमपेज:**
ड्रैग-एंड-ड्रॉप विजेट ग्रिड के साथ पूरी तरह से कस्टमाइज़ करने योग्य होमपेज। होस्ट स्टेटस, सर्विस लिंक, घड़ियाँ, नोट्स, RSS फ़ीड, मौसम, Docker कंटेनर, होस्ट मेट्रिक्स चार्ट, एम्बेडेड टर्मिनल, iframes और अन्य के लिए विजेट जोड़ें।
</td>
<td width="50%" valign="top">
@@ -205,8 +170,7 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
- **कमांड इतिहास** - पहले चलाए गए SSH कमांड का ऑटो-कम्प्लीट और दृश्य
- **क्विक कनेक्ट** - कनेक्शन डेटा सहेजे बिना सर्वर से कनेक्ट करें
- **कमांड पैलेट** - अपने कीबोर्ड से SSH कनेक्शन तक त्वरित पहुँच के लिए बाएँ Shift को दो बार टैप करें
- **Proxmox एकीकरण** - अपने Proxmox इंस्टेंस से Termix में होस्ट स्वचालित रूप से जोड़ें
- **SSH सुविधाओं से भरपूर** - जम्प होस्ट, Warpgate, TOTP आधारित कनेक्शन, SOCKS5, होस्ट की वेरिफ़िकेशन, पासवर्ड ऑटोफ़िल, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, पोर्ट नॉकिंग, टर्मिनल लॉगिंग आदि का सपोर्ट
- **SSH सुविधाओं से भरपूर** - जम्प होस्ट, Warpgate, TOTP आधारित कनेक्शन, SOCKS5, होस्ट की वेरिफ़िकेशन, पासवर्ड ऑटोफ़िल, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, पोर्ट नॉकिंग आदि का सपोर्ट
</details>
@@ -288,14 +252,6 @@ networks:
<br />
## दान करें
Termix मुफ़्त और ओपन सोर्स है। यदि आपको यह उपयोगी लगता है, तो सर्वर लागत और विकास समय में मदद के लिए [दान करें](https://donate.termix.site/)।
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
## स्क्रीनशॉट
<div align="center">
@@ -338,10 +294,6 @@ Termix मुफ़्त और ओपन सोर्स है। यदि
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
</tr>
<tr>
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
</tr>
</table>
<sub>कुछ वीडियो और छवियाँ पुरानी हो सकती हैं या विशेषताओं को पूरी तरह से प्रदर्शित नहीं कर सकती हैं।</sub>
@@ -352,7 +304,7 @@ Termix मुफ़्त और ओपन सोर्स है। यदि
## नियोजित विशेषताएँ
सभी नियोजित विशेषताओं के लिए [प्रोजेक्ट्स](https://github.com/orgs/Termix-SSH/projects/5) देखें। यदि आप योगदान देना चाहते हैं, तो [योगदान](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) देखें।
सभी नियोजित विशेषताओं के लिए [प्रोजेक्ट्स](https://github.com/orgs/Termix-SSH/projects/2) देखें। यदि आप योगदान देना चाहते हैं, तो [योगदान](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) देखें।
<br />
+8 -56
View File
@@ -28,17 +28,10 @@
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
</p>
<br />
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.
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
<br />
@@ -94,65 +87,37 @@ Gestisci i file direttamente sui server remoti con supporto per la visualizzazio
<tr>
<td width="50%" valign="top">
**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.
**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.
</td>
<td width="50%" valign="top">
**Gestore Host SSH:**
Salva, organizza e gestisci le tue connessioni SSH con tag e cartelle (con personalizzazione delle cartelle e supporto per cartelle annidate), salva facilmente le informazioni di accesso riutilizzabili e automatizza il deployment delle chiavi SSH.
Salva, organizza e gestisci le tue connessioni SSH con tag e cartelle, salva facilmente le informazioni di accesso riutilizzabili e automatizza il deployment delle chiavi SSH.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Metriche Host:**
Visualizza l'utilizzo di CPU, memoria, disco, rete, uptime, informazioni di sistema, firewall, monitoraggio porte, visualizzatore di log, utenti/permessi, certificati e molto altro sulla maggior parte dei server basati su Linux.
**Statistiche Server:**
Visualizza l'utilizzo di CPU, memoria e disco insieme a rete, uptime, informazioni di sistema, firewall, monitoraggio porte sulla maggior parte dei server basati su Linux.
</td>
<td width="50%" valign="top">
**Autenticazione Utente:**
Gestione utenti sicura con controlli amministrativi e supporto OIDC/LDAP/SSO (con controllo degli accessi) e 2FA (TOTP). Visualizza le sessioni utente attive su tutte le piattaforme e revoca i permessi. Collega i tuoi account OIDC/Locali tra loro. Visualizza il log di controllo delle azioni di tutti gli utenti.
Gestione utenti sicura con controlli amministrativi e supporto OIDC (con controllo degli accessi) e 2FA (TOTP). Visualizza le sessioni utente attive su tutte le piattaforme e revoca i permessi. Collega i tuoi account OIDC/Locali tra loro.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
**RBAC:**
Crea ruoli e condividi host tra utenti/ruoli.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
**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.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
@@ -205,8 +170,7 @@ Supporto integrato per circa 30 lingue (gestito da [Crowdin](https://docs.termix
- **Cronologia Comandi** - Autocompletamento e visualizzazione dei comandi SSH eseguiti in precedenza
- **Connessione Rapida** - Connettiti a un server senza dover salvare i dati di connessione
- **Palette Comandi** - Premi due volte shift sinistro per accedere rapidamente alle connessioni SSH con la tastiera
- **Integrazione Proxmox** - Aggiungi automaticamente host a Termix dalla tua istanza Proxmox
- **SSH Ricco di Funzionalita** - Supporta jump host, Warpgate, connessioni basate su TOTP, SOCKS5, verifica chiave host, compilazione automatica password, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, registrazione terminale, ecc.
- **SSH Ricco di Funzionalita** - Supporta jump host, Warpgate, connessioni basate su TOTP, SOCKS5, verifica chiave host, compilazione automatica password, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, ecc.
</details>
@@ -288,14 +252,6 @@ networks:
<br />
## Dona
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.
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
## Screenshot
<div align="center">
@@ -338,10 +294,6 @@ Termix è gratuito e open source. Se lo trovi utile, considera di [donare](https
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
</tr>
<tr>
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
</tr>
</table>
<sub>Alcuni video e immagini potrebbero non essere aggiornati o potrebbero non mostrare perfettamente le funzionalita.</sub>
@@ -352,7 +304,7 @@ Termix è gratuito e open source. Se lo trovi utile, considera di [donare](https
## Funzionalita Pianificate
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).
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).
<br />
+8 -56
View File
@@ -28,17 +28,10 @@
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
</p>
<br />
Termix は無料のオープンソースプロジェクトです。便利だと感じた場合は、サーバーコストと開発時間のために[寄付](https://donate.termix.site/)をご検討ください。
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
<br />
@@ -94,65 +87,37 @@ Termixは、オープンソースで永久無料のセルフホスト型オー
<tr>
<td width="50%" valign="top">
**DockerおよびPodman管理:**
コンテナの起動、停止、一時停止、削除。コンテナの統計情報を表示。docker execターミナルでコンテナを操作。DockerとPodmanの両方をコンテナランタイムとしてサポートしています。PortainerやDockgeの代替ではなく、コンテナの作成よりも簡易的な管理を目的としています。
**Docker管理:**
コンテナの起動、停止、一時停止、削除。コンテナの統計情報を表示。docker execターミナルでコンテナを操作。PortainerやDockgeの代替ではなく、コンテナの作成よりも簡易的な管理を目的としています。
</td>
<td width="50%" valign="top">
**SSHホストマネージャー:**
タグやフォルダ(フォルダのカスタマイズとネストフォルダ対応)でSSH接続を保存、整理、管理し、再利用可能なログイン情報を簡単に保存しながらSSHキーのデプロイを自動化できます。
タグやフォルダでSSH接続を保存、整理、管理し、再利用可能なログイン情報を簡単に保存しながらSSHキーのデプロイを自動化できます。
</td>
</tr>
<tr>
<td width="50%" valign="top">
**ホストメトリクス:**
ほとんどのLinuxベースのサーバーで、CPU、メモリ、ディスク使用量、ネットワーク、アップタイム、システム情報、ファイアウォール、ポートモニター、ログビューア、ユーザー/権限、証明書など、さらに多くの情報を表示できます。
**サーバー統計:**
ほとんどのLinuxベースのサーバーで、CPU、メモリ、ディスク使用量、ネットワーク、アップタイム、システム情報、ファイアウォール、ポートモニターを表示できます。
</td>
<td width="50%" valign="top">
**ユーザー認証:**
管理者コントロールとOIDC/LDAP/SSO(アクセス制御付き)および2FA(TOTP)対応による安全なユーザー管理。すべてのプラットフォームでアクティブなユーザーセッションを表示し、権限を取り消し可能。OIDC/ローカルアカウントの連携が可能です。すべてのユーザー操作の監査ログを表示できます。
管理者コントロールとOIDC(アクセス制御付き)および2FA(TOTP)対応による安全なユーザー管理。すべてのプラットフォームでアクティブなユーザーセッションを表示し、権限を取り消し可能。OIDC/ローカルアカウントの連携が可能です。
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Tailscaleインテグレーション:**
TailnetのデバイスをリストしてホストとしてすばやくH追加し、Tailscale SSHを認証方法として使用して接続します。これにより、TailnetのACLが認証情報を保存せずに認可を処理します。
</td>
<td width="50%" valign="top">
**RBAC:**
ロールを作成し、ユーザー/ロール間でホストを共有できます。
</td>
</tr>
<tr>
<td width="50%" valign="top">
**シリアル接続:**
ブラウザまたはデスクトップアプリからシリアルデバイス(ルーター、スイッチ、マイクロコントローラーなど)に直接接続できます。ボーレート、データビット、ストップビット、パリティを設定できます。対応ブラウザではWeb Serial APIを使用し、Electronアプリではネイティブバックエンドを使用します。
</td>
<td width="50%" valign="top">
**アラート:**
ホストメトリクス(CPU、メモリ、ディスクなど)に対してしきい値ベースのアラートルールを設定し、発動時にntfyまたはwebhookで通知を受け取れます。発動中および解決済みのアラートを履歴ログで確認できます。
</td>
</tr>
<tr>
<td width="50%" valign="top">
**ホームページ:**
ドラッグ&ドロップのウィジェットグリッドを備えた完全カスタマイズ可能なホームページ。ホストステータス、サービスリンク、時計、メモ、RSSフィード、天気、Dockerコンテナ、ホストメトリクスグラフ、埋め込みターミナル、iframeなどのウィジェットを追加できます。
</td>
<td width="50%" valign="top">
@@ -205,8 +170,7 @@ TailnetのデバイスをリストしてホストとしてすばやくH追加し
- **コマンド履歴** - 過去に実行したSSHコマンドの自動補完と表示が可能です
- **クイック接続** - 接続データを保存せずにサーバーに接続できます
- **コマンドパレット** - 左Shiftキーを2回押すことで、キーボードからSSH接続に素早くアクセスできます
- **Proxmox統合** - Proxmoxインスタンスからホストを自動的にTermixに追加できます
- **SSH機能充実** - ジャンプホスト、Warpgate、TOTPベースの接続、SOCKS5、ホストキー検証、パスワード自動入力、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、ポート敲き(port knocking)、ターミナルログ記録などに対応しています
- **SSH機能充実** - ジャンプホスト、Warpgate、TOTPベースの接続、SOCKS5、ホストキー検証、パスワード自動入力、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、ポート敲き(port knocking)などに対応しています
</details>
@@ -288,14 +252,6 @@ networks:
<br />
## 寄付
Termix は無料のオープンソースプロジェクトです。便利だと感じた場合は、サーバーコストと開発時間のために[寄付](https://donate.termix.site/)をご検討ください。
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
## スクリーンショット
<div align="center">
@@ -338,10 +294,6 @@ Termix は無料のオープンソースプロジェクトです。便利だと
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
</tr>
<tr>
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
</tr>
</table>
<sub>動画や画像の一部は最新ではない場合や、機能を完全に紹介できていない場合があります。</sub>
@@ -352,7 +304,7 @@ Termix は無料のオープンソースプロジェクトです。便利だと
## 予定されている機能
すべての予定機能については[Projects](https://github.com/orgs/Termix-SSH/projects/5)をご覧ください。コントリビュートをご希望の方は[Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)をご覧ください。
すべての予定機能については[Projects](https://github.com/orgs/Termix-SSH/projects/2)をご覧ください。コントリビュートをご希望の方は[Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)をご覧ください。
<br />
+8 -56
View File
@@ -28,17 +28,10 @@
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
</p>
<br />
Termix는 무료 오픈소스 프로젝트입니다. 유용하게 사용하고 있다면 서버 비용과 개발 시간을 위해 [후원](https://donate.termix.site/)을 고려해 주세요.
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
<br />
@@ -94,65 +87,37 @@ Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버
<tr>
<td width="50%" valign="top">
**Docker 및 Podman 관리:**
컨테이너 시작, 중지, 일시 정지, 제거. 컨테이너 통계 보기. docker exec 터미널로 컨테이너 제어. Docker와 Podman을 모두 컨테이너 런타임으로 지원. Portainer나 Dockge를 대체하기 위한 것이 아니라 컨테이너 생성보다는 간편한 관리를 목적으로 합니다.
**Docker 관리:**
컨테이너 시작, 중지, 일시 정지, 제거. 컨테이너 통계 보기. docker exec 터미널로 컨테이너 제어. Portainer나 Dockge를 대체하기 위한 것이 아니라 컨테이너 생성보다는 간편한 관리를 목적으로 합니다.
</td>
<td width="50%" valign="top">
**SSH 호스트 관리자:**
태그와 폴더(폴더 사용자 지정 및 중첩 폴더 지원)로 SSH 연결을 저장, 정리, 관리하고, 재사용 가능한 로그인 정보를 쉽게 저장하면서 SSH 키 배포를 자동화.
태그와 폴더로 SSH 연결을 저장, 정리, 관리하고, 재사용 가능한 로그인 정보를 쉽게 저장하면서 SSH 키 배포를 자동화.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**호스트 메트릭:**
대부분의 Linux 기반 서버에서 CPU, 메모리, 디스크 사용량, 네트워크, 업타임, 시스템 정보, 방화벽, 포트 모니터, 로그 뷰어, 사용자/권한, 인증서 등 다양한 정보를 표시.
**서버 통계:**
대부분의 Linux 기반 서버에서 CPU, 메모리, 디스크 사용량과 함께 네트워크, 업타임, 시스템 정보, 방화벽, 포트 모니터를 표시.
</td>
<td width="50%" valign="top">
**사용자 인증:**
관리자 제어와 OIDC/LDAP/SSO(액세스 제어 포함) 및 2FA(TOTP) 지원을 통한 안전한 사용자 관리. 모든 플랫폼에서 활성 사용자 세션을 보고 권한을 취소 가능. OIDC/로컬 계정 연동. 모든 사용자 작업의 감사 로그 조회.
관리자 제어와 OIDC(액세스 제어 포함) 및 2FA(TOTP) 지원을 통한 안전한 사용자 관리. 모든 플랫폼에서 활성 사용자 세션을 보고 권한을 취소 가능. OIDC/로컬 계정 연동.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Tailscale 통합:**
Tailscale 네트워크의 기기를 나열하여 호스트로 빠르게 추가하고, Tailscale SSH를 인증 방법으로 사용하여 연결함으로써 자격 증명을 저장하지 않고도 네트워크 ACL이 권한 부여를 처리하도록 합니다.
</td>
<td width="50%" valign="top">
**RBAC:**
역할을 생성하고 사용자/역할 간에 호스트 공유.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**시리얼 연결:**
브라우저 또는 데스크톱 앱에서 직접 시리얼 장치(라우터, 스위치, 마이크로컨트롤러 등)에 연결. 보드레이트, 데이터 비트, 스톱 비트, 패리티 구성. 지원 브라우저에서는 Web Serial API를, Electron 앱에서는 네이티브 백엔드를 사용합니다.
</td>
<td width="50%" valign="top">
**알림:**
호스트 메트릭(CPU, 메모리, 디스크 등)에 대한 임계값 기반 알림 규칙을 설정하고 트리거될 때 ntfy 또는 웹훅을 통해 알림 수신. 기록 로그에서 발생 중인 알림과 해결된 알림 확인.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**홈페이지:**
드래그 앤 드롭 위젯 그리드를 갖춘 완전 맞춤형 홈페이지. 호스트 상태, 서비스 링크, 시계, 메모, RSS 피드, 날씨, Docker 컨테이너, 호스트 메트릭 차트, 임베디드 터미널, iframe 등의 위젯 추가 가능.
</td>
<td width="50%" valign="top">
@@ -205,8 +170,7 @@ Tailscale 네트워크의 기기를 나열하여 호스트로 빠르게 추가
- **명령어 기록** - 이전에 실행한 SSH 명령어의 자동 완성 및 조회
- **빠른 연결** - 연결 데이터를 저장하지 않고 서버에 접속
- **명령어 팔레트** - 왼쪽 Shift 키를 두 번 눌러 키보드로 SSH 연결에 빠르게 접근
- **Proxmox 통합** - Proxmox 인스턴스에서 Termix로 호스트를 자동 추가
- **풍부한 SSH 기능** - 점프 호스트, Warpgate, TOTP 기반 연결, SOCKS5, 호스트 키 검증, 비밀번호 자동 입력, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, 포트 노킹, 터미널 로깅 등 지원
- **풍부한 SSH 기능** - 점프 호스트, Warpgate, TOTP 기반 연결, SOCKS5, 호스트 키 검증, 비밀번호 자동 입력, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, 포트 노킹 등 지원
</details>
@@ -288,14 +252,6 @@ networks:
<br />
## 후원
Termix는 무료 오픈소스 프로젝트입니다. 유용하게 사용하고 있다면 서버 비용과 개발 시간을 위해 [후원](https://donate.termix.site/)을 고려해 주세요.
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
## 스크린샷
<div align="center">
@@ -338,10 +294,6 @@ Termix는 무료 오픈소스 프로젝트입니다. 유용하게 사용하고
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
</tr>
<tr>
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
</tr>
</table>
<sub>일부 비디오 및 이미지는 최신이 아니거나 기능을 완벽하게 보여주지 않을 수 있습니다.</sub>
@@ -352,7 +304,7 @@ Termix는 무료 오픈소스 프로젝트입니다. 유용하게 사용하고
## 계획된 기능
모든 계획된 기능은 [Projects](https://github.com/orgs/Termix-SSH/projects/5)를 참조하세요. 기여를 원하시면 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)을 참조하세요.
모든 계획된 기능은 [Projects](https://github.com/orgs/Termix-SSH/projects/2)를 참조하세요. 기여를 원하시면 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)을 참조하세요.
<br />
+8 -56
View File
@@ -28,17 +28,10 @@
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
</p>
<br />
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.
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
<br />
@@ -94,65 +87,37 @@ Gerencie arquivos diretamente em servidores remotos com suporte para visualizar
<tr>
<td width="50%" valign="top">
**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.
**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.
</td>
<td width="50%" valign="top">
**Gerenciador de Hosts SSH:**
Salve, organize e gerencie suas conexoes SSH com tags e pastas (com personalizacao de pastas e suporte a pastas aninhadas), e salve facilmente informacoes de login reutilizaveis com a capacidade de automatizar a implantacao de chaves SSH.
Salve, organize e gerencie suas conexoes SSH com tags e pastas, e salve facilmente informacoes de login reutilizaveis com a capacidade de automatizar a implantacao de chaves SSH.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Metricas do Host:**
Visualize o uso de CPU, memoria e disco, rede, tempo de atividade, informacoes do sistema, firewall, monitor de portas, visualizador de logs, usuarios/permissoes, certificados e muito mais na maioria dos servidores baseados em Linux.
**Estatisticas do Servidor:**
Visualize o uso de CPU, memoria e disco junto com rede, tempo de atividade, informacoes do sistema, firewall, monitor de portas na maioria dos servidores baseados em Linux.
</td>
<td width="50%" valign="top">
**Autenticacao de Usuarios:**
Gerenciamento seguro de usuarios com controles de administrador e suporte para OIDC/LDAP/SSO (com controle de acesso) e 2FA (TOTP). Visualize sessoes ativas de usuarios em todas as plataformas e revogue permissoes. Vincule suas contas OIDC/Locais entre si. Visualize o log de auditoria de todas as acoes dos usuarios.
Gerenciamento seguro de usuarios com controles de administrador e suporte para OIDC (com controle de acesso) e 2FA (TOTP). Visualize sessoes ativas de usuarios em todas as plataformas e revogue permissoes. Vincule suas contas OIDC/Locais entre si.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
**RBAC:**
Crie funcoes e compartilhe hosts entre usuarios/funcoes.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
**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.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
@@ -205,8 +170,7 @@ Suporte integrado para aproximadamente 30 idiomas (gerenciado pelo [Crowdin](htt
- **Historico de Comandos** - Autocompletar e visualizar comandos SSH executados anteriormente
- **Conexao Rapida** - Conecte-se a um servidor sem precisar salvar os dados de conexao
- **Paleta de Comandos** - Pressione duas vezes a tecla Shift esquerda para acessar rapidamente as conexoes SSH com seu teclado
- **Integracao com Proxmox** - Adicione automaticamente hosts ao Termix a partir da sua instancia Proxmox
- **SSH Rico em Funcionalidades** - Suporta jump hosts, Warpgate, conexoes baseadas em TOTP, SOCKS5, verificacao de chave do host, preenchimento automatico de senhas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, registro de terminal, etc.
- **SSH Rico em Funcionalidades** - Suporta jump hosts, Warpgate, conexoes baseadas em TOTP, SOCKS5, verificacao de chave do host, preenchimento automatico de senhas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc.
</details>
@@ -288,14 +252,6 @@ networks:
<br />
## Doar
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.
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
## Capturas de Tela
<div align="center">
@@ -338,10 +294,6 @@ Termix é gratuito e de código aberto. Se o achar útil, considere [doar](https
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
</tr>
<tr>
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
</tr>
</table>
<sub>Alguns videos e imagens podem estar desatualizados ou podem nao mostrar perfeitamente as funcionalidades.</sub>
@@ -352,7 +304,7 @@ Termix é gratuito e de código aberto. Se o achar útil, considere [doar](https
## Funcionalidades Planejadas
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).
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).
<br />
+8 -56
View File
@@ -28,17 +28,10 @@
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
</p>
<br />
Termix — бесплатный проект с открытым исходным кодом. Если он вам полезен, рассмотрите возможность [пожертвования](https://donate.termix.site/) для покрытия расходов на серверы и время разработки.
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
<br />
@@ -94,65 +87,37 @@ Termix - это платформа для управления серверам
<tr>
<td width="50%" valign="top">
**Управление Docker и Podman:**
Запуск, остановка, приостановка, удаление контейнеров. Просмотр статистики контейнеров. Управление контейнером через терминал docker exec. Поддерживает как Docker, так и Podman в качестве среды выполнения контейнеров. Не предназначен для замены Portainer или Dockge, а скорее для простого управления контейнерами по сравнению с их созданием.
**Управление Docker:**
Запуск, остановка, приостановка, удаление контейнеров. Просмотр статистики контейнеров. Управление контейнером через терминал docker exec. Не предназначен для замены Portainer или Dockge, а скорее для простого управления контейнерами по сравнению с их созданием.
</td>
<td width="50%" valign="top">
**Менеджер SSH-хостов:**
Сохранение, организация и управление SSH-подключениями с помощью тегов и папок (с настройкой папок и поддержкой вложенных папок), с возможностью сохранения данных для повторного входа и автоматизации развёртывания SSH-ключей.
Сохранение, организация и управление SSH-подключениями с помощью тегов и папок, с возможностью сохранения данных для повторного входа и автоматизации развёртывания SSH-ключей.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Метрики хоста:**
Просмотр использования CPU, памяти и диска, сети, времени работы, информации о системе, файрвола, монитора портов, просмотрщика логов, пользователей/прав доступа, сертификатов и многого другого на большинстве серверов на базе Linux.
**Статистика сервера:**
Просмотр использования CPU, памяти и диска, а также сети, времени работы, информации о системе, файрвола и монитора портов на большинстве серверов на базе Linux.
</td>
<td width="50%" valign="top">
**Аутентификация пользователей:**
Безопасное управление пользователями с административным контролем и поддержкой OIDC/LDAP/SSO (с контролем доступа) и 2FA (TOTP). Просмотр активных сессий пользователей на всех платформах и отзыв прав доступа. Связывание аккаунтов OIDC/локальных аккаунтов. Просмотр журнала аудита действий всех пользователей.
Безопасное управление пользователями с административным контролем и поддержкой OIDC (с контролем доступа) и 2FA (TOTP). Просмотр активных сессий пользователей на всех платформах и отзыв прав доступа. Связывание аккаунтов OIDC/локальных аккаунтов.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Интеграция с Tailscale:**
Список устройств вашей сети Tailscale для быстрого добавления их в качестве хостов и подключение через Tailscale SSH в качестве метода аутентификации, позволяя ACL вашей сети управлять авторизацией без хранения учётных данных.
</td>
<td width="50%" valign="top">
**RBAC:**
Создание ролей и предоставление общего доступа к хостам для пользователей/ролей.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Последовательные подключения:**
Подключение к последовательным устройствам (маршрутизаторы, коммутаторы, микроконтроллеры и т. д.) напрямую из браузера или приложения для рабочего стола. Настройка скорости передачи данных, битов данных, стоп-битов и чётности. Использует Web Serial API в поддерживаемых браузерах или нативный бэкенд в приложении Electron.
</td>
<td width="50%" valign="top">
**Оповещения:**
Настройте правила оповещений на основе пороговых значений для метрик хоста (CPU, память, диск и т. д.) и получайте уведомления через ntfy или вебхуки при их срабатывании. Просматривайте активные и разрешённые оповещения в журнале истории.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Домашняя страница:**
Полностью настраиваемая домашняя страница с сеткой виджетов с перетаскиванием. Добавляйте виджеты для статуса хоста, ссылок на сервисы, часов, заметок, RSS-лент, погоды, контейнеров Docker, графиков метрик хоста, встроенных терминалов, iframe и многого другого.
</td>
<td width="50%" valign="top">
@@ -205,8 +170,7 @@ SSH-сессии и вкладки остаются открытыми на вс
- **История команд** - Автодополнение и просмотр ранее выполненных SSH-команд
- **Быстрое подключение** - Подключение к серверу без необходимости сохранения данных подключения
- **Командная палитра** - Двойное нажатие левого Shift для быстрого доступа к SSH-подключениям с клавиатуры
- **Интеграция с Proxmox** - Автоматическое добавление хостов в Termix из вашего экземпляра Proxmox
- **Богатый функционал SSH** - Поддержка jump-хостов, Warpgate, подключений на основе TOTP, SOCKS5, верификации ключей хоста, автозаполнения паролей, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, логирования терминала и др.
- **Богатый функционал SSH** - Поддержка jump-хостов, Warpgate, подключений на основе TOTP, SOCKS5, верификации ключей хоста, автозаполнения паролей, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking и др.
</details>
@@ -288,14 +252,6 @@ networks:
<br />
## Пожертвование
Termix — бесплатный проект с открытым исходным кодом. Если он вам полезен, рассмотрите возможность [пожертвования](https://donate.termix.site/) для покрытия расходов на серверы и время разработки.
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
## Скриншоты
<div align="center">
@@ -338,10 +294,6 @@ Termix — бесплатный проект с открытым исходны
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
</tr>
<tr>
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
</tr>
</table>
<sub>Некоторые видео и изображения могут быть устаревшими или не полностью отражать функциональность.</sub>
@@ -352,7 +304,7 @@ Termix — бесплатный проект с открытым исходны
## Запланированные функции
Смотрите [Проекты](https://github.com/orgs/Termix-SSH/projects/5) для просмотра всех запланированных функций. Если вы хотите внести вклад, смотрите [Участие в разработке](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
Смотрите [Проекты](https://github.com/orgs/Termix-SSH/projects/2) для просмотра всех запланированных функций. Если вы хотите внести вклад, смотрите [Участие в разработке](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
<br />
+8 -56
View File
@@ -28,17 +28,10 @@
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
</p>
<br />
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.
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
<br />
@@ -94,65 +87,37 @@ Uzak sunuculardaki dosyalari dogrudan yonetin; kod, goruntu, ses ve video gorunt
<tr>
<td width="50%" valign="top">
**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.
**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.
</td>
<td width="50%" valign="top">
**SSH Ana Bilgisayar Yoneticisi:**
SSH baglantilarinizi etiketler ve klasorlerle (klasor ozellestirme ve ic ice klasor destegi ile) kaydedin, duzenleyin ve yonetin; yeniden kullanilabilir giris bilgilerini kolayca kaydedin ve SSH anahtarlarinin dagitimini otomatiklestirin.
SSH baglantilarinizi etiketler ve klasorlerle kaydedin, duzenleyin ve yonetin; yeniden kullanilabilir giris bilgilerini kolayca kaydedin ve SSH anahtarlarinin dagitimini otomatiklestirin.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Ana Bilgisayar Metrikleri:**
Cogu Linux tabanli sunucularda CPU, bellek, disk kullanimi, ag, calisma suresi, sistem bilgisi, guvenlik duvari, port izleme, gunluk goruntuleyici, kullanicilar/izinler, sertifikalar ve daha fazlasini goruntuleyin.
**Sunucu Istatistikleri:**
Cogu Linux tabanli sunucularda CPU, bellek ve disk kullanimini ag, calisma suresi, sistem bilgisi, guvenlik duvari, port izleme ile birlikte goruntuleyin.
</td>
<td width="50%" valign="top">
**Kullanici Kimlik Dogrulama:**
Yonetici kontrolleri, OIDC/LDAP/SSO (erisim kontrollu) ve 2FA (TOTP) destegi ile guvenli kullanici yonetimi. Tum platformlardaki aktif kullanici oturumlarini goruntuleyin ve izinleri iptal edin. OIDC/Yerel hesaplarinizi birbirine baglayin. Tum kullanicilarin islemlerinin denetim gunlugunu goruntuleyin.
Yonetici kontrolleri, OIDC (erisim kontrollu) ve 2FA (TOTP) destegi ile guvenli kullanici yonetimi. Tum platformlardaki aktif kullanici oturumlarini goruntuleyin ve izinleri iptal edin. OIDC/Yerel hesaplarinizi birbirine baglayin.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
**RBAC:**
Roller olusturun ve ana bilgisayarlari kullanicilar/roller arasinda paylasin.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
**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.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
@@ -205,8 +170,7 @@ Yaklasik 30 dil icin yerlesik destek ([Crowdin](https://docs.termix.site/transla
- **Komut Gecmisi** - Daha once calistirilan SSH komutlarini otomatik tamamlayin ve goruntuleyin
- **Hizli Baglanti** - Baglanti verilerini kaydetmeden bir sunucuya baglanin
- **Komut Paleti** - Sol shift tusuna iki kez basarak SSH baglantilariniza klavyenizle hizlica erisin
- **Proxmox Entegrasyonu** - Proxmox ornekinizden Termix'e otomatik olarak ana bilgisayar ekleyin
- **SSH Zengin Ozellikler** - Atlama ana bilgisayarlari, Warpgate, TOTP tabanli baglantilar, SOCKS5, ana bilgisayar anahtar dogrulama, otomatik sifre doldurma, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, terminal gunlukleme vb. destekler.
- **SSH Zengin Ozellikler** - Atlama ana bilgisayarlari, Warpgate, TOTP tabanli baglantilar, SOCKS5, ana bilgisayar anahtar dogrulama, otomatik sifre doldurma, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking vb. destekler.
</details>
@@ -288,14 +252,6 @@ networks:
<br />
## Bağış Yapın
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.
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
## Ekran Goruntuleri
<div align="center">
@@ -338,10 +294,6 @@ Termix ücretsiz ve açık kaynaklıdır. Faydalı buluyorsanız, sunucu maliyet
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
</tr>
<tr>
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
</tr>
</table>
<sub>Bazi videolar ve gorseller guncel olmayabilir veya ozellikleri tam olarak yansitmayabilir.</sub>
@@ -352,7 +304,7 @@ Termix ücretsiz ve açık kaynaklıdır. Faydalı buluyorsanız, sunucu maliyet
## Planlanan Ozellikler
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.
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.
<br />
+8 -56
View File
@@ -28,17 +28,10 @@
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
</p>
<br />
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.
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
<br />
@@ -94,65 +87,37 @@ Quan ly tep truc tiep tren may chu tu xa voi ho tro xem va chinh sua ma, hinh an
<tr>
<td width="50%" valign="top">
**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.
**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.
</td>
<td width="50%" valign="top">
**Trinh Quan Ly May Chu SSH:**
Luu, sap xep va quan ly cac ket noi SSH cua ban voi the va thu muc (ho tro tuy chinh thu muc va thu muc long nhau), de dang luu thong tin dang nhap co the tai su dung dong thoi co the tu dong hoa viec trien khai khoa SSH.
Luu, sap xep va quan ly cac ket noi SSH cua ban voi the va thu muc, de dang luu thong tin dang nhap co the tai su dung dong thoi co the tu dong hoa viec trien khai khoa SSH.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Chi So May Chu:**
Xem muc su dung CPU, bo nho, o dia, mang, thoi gian hoat dong, thong tin he thong, tuong lua, giam sat cong, trinh xem nhat ky, nguoi dung/quyen, chung chi va nhieu hon nua tren hau het cac may chu chay Linux.
**Thong Ke May Chu:**
Xem muc su dung CPU, bo nho va o dia cung voi mang, thoi gian hoat dong, thong tin he thong, tuong lua, giam sat cong tren hau het cac may chu chay Linux.
</td>
<td width="50%" valign="top">
**Xac Thuc Nguoi Dung:**
Quan ly nguoi dung an toan voi quyen quan tri va ho tro OIDC/LDAP/SSO (co kiem soat truy cap) va 2FA (TOTP). Xem phien hoat dong cua nguoi dung tren tat ca cac nen tang va thu hoi quyen. Lien ket tai khoan OIDC/Noi bo cua ban voi nhau. Xem nhat ky kiem toan cac hanh dong cua tat ca nguoi dung.
Quan ly nguoi dung an toan voi quyen quan tri va ho tro OIDC (co kiem soat truy cap) va 2FA (TOTP). Xem phien hoat dong cua nguoi dung tren tat ca cac nen tang va thu hoi quyen. Lien ket tai khoan OIDC/Noi bo cua ban voi nhau.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
**RBAC:**
Tao vai tro va chia se may chu giua nguoi dung/vai tro.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
**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.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**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.
</td>
<td width="50%" valign="top">
@@ -205,8 +170,7 @@ Ho tro tich hop khoang 30 ngon ngu (duoc quan ly boi [Crowdin](https://docs.term
- **Lich Su Lenh** - Tu dong hoan thanh va xem cac lenh SSH da chay truoc do
- **Ket Noi Nhanh** - Ket noi den may chu ma khong can luu du lieu ket noi
- **Bang Lenh** - Nhan dup phim shift trai de truy cap nhanh cac ket noi SSH bang ban phim
- **Tich Hop Proxmox** - Tu dong them may chu vao Termix tu instance Proxmox cua ban
- **SSH Giau Tinh Nang** - Ho tro jump host, Warpgate, ket noi dua tren TOTP, SOCKS5, xac minh khoa may chu, tu dong dien mat khau, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, ghi nhat ky terminal, v.v.
- **SSH Giau Tinh Nang** - Ho tro jump host, Warpgate, ket noi dua tren TOTP, SOCKS5, xac minh khoa may chu, tu dong dien mat khau, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, v.v.
</details>
@@ -288,14 +252,6 @@ networks:
<br />
## Quyên góp
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.
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
## Anh Chup Man Hinh
<div align="center">
@@ -338,10 +294,6 @@ Termix là dự án miễn phí và mã nguồn mở. Nếu bạn thấy hữu
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
</tr>
<tr>
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
</tr>
</table>
<sub>Mot so video va hinh anh co the da loi thoi hoac khong the hien chinh xac hoan toan cac tinh nang.</sub>
@@ -352,7 +304,7 @@ Termix là dự án miễn phí và mã nguồn mở. Nếu bạn thấy hữu
## Tinh Nang Du Kien
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).
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).
<br />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 527 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 534 KiB

After

Width:  |  Height:  |  Size: 318 KiB

-120
View File
@@ -1,120 +0,0 @@
const API = "https://api.crowdin.com/api/v2";
const ENGINE_ID = 649248; // Google Translate MT engine
const PROJECT_NAME = "termix-ssh";
const SOURCE_FILE = "en.json";
function token() {
const t = process.env.CROWDIN_API_KEY;
if (!t) throw new Error("CROWDIN_API_KEY is not set");
return t;
}
async function request(method, pathname, body) {
const res = await fetch(`${API}${pathname}`, {
method,
headers: {
Authorization: `Bearer ${token()}`,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${method} ${pathname} -> ${res.status}: ${text}`);
}
return res.json();
}
async function paged(pathname) {
const items = [];
let offset = 0;
const limit = 500;
for (;;) {
const sep = pathname.includes("?") ? "&" : "?";
const page = await request(
"GET",
`${pathname}${sep}limit=${limit}&offset=${offset}`,
);
const batch = (page.data || []).map((row) => row.data);
items.push(...batch);
if (batch.length < limit) break;
offset += limit;
}
return items;
}
async function resolveProjectId() {
const projects = await paged("/projects");
const match = projects.find(
(p) => p.identifier === PROJECT_NAME || p.name === PROJECT_NAME,
);
if (!match) throw new Error(`project "${PROJECT_NAME}" not found`);
return { id: match.id, targetLanguageIds: match.targetLanguageIds || [] };
}
async function resolveFileId(projectId) {
const files = await paged(`/projects/${projectId}/files`);
const match = files.find(
(f) => f.name === SOURCE_FILE || f.path === `/${SOURCE_FILE}`,
);
if (!match)
throw new Error(`source file "${SOURCE_FILE}" not found in project`);
return match.id;
}
async function pollPreTranslation(projectId, preTranslationId) {
for (;;) {
const { data } = await request(
"GET",
`/projects/${projectId}/pre-translations/${preTranslationId}`,
);
if (data.status === "finished") return data;
if (data.status === "failed" || data.status === "canceled") {
throw new Error(
`pre-translation ${data.status} (progress ${data.progress}%)`,
);
}
await new Promise((r) => setTimeout(r, 5000));
}
}
async function main() {
const { id: projectId, targetLanguageIds } = await resolveProjectId();
if (targetLanguageIds.length === 0) {
throw new Error("project has no target languages configured");
}
const fileId = await resolveFileId(projectId);
console.log(
`Pre-translating project ${projectId}, file ${fileId}, ${targetLanguageIds.length} languages via MT engine ${ENGINE_ID}`,
);
const { data } = await request(
"POST",
`/projects/${projectId}/pre-translations`,
{
languageIds: targetLanguageIds,
fileIds: [fileId],
method: "mt",
engineId: ENGINE_ID,
scope: "untranslated",
},
);
const result = await pollPreTranslation(projectId, data.identifier);
console.log(`Pre-translation finished (${result.progress}%)`);
}
module.exports = {
resolveProjectId,
resolveFileId,
pollPreTranslation,
};
if (require.main === module) {
main().catch((err) => {
console.error(`crowdin-pretranslate: ${err.message}`);
process.exit(1);
});
}
-121
View File
@@ -1,121 +0,0 @@
const fs = require("fs");
const path = require("path");
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg.startsWith("--")) {
const key = arg.slice(2);
const next = argv[i + 1];
if (next === undefined || next.startsWith("--")) {
args[key] = true;
} else {
args[key] = next;
i++;
}
}
}
return args;
}
function fail(message) {
console.error(`generate-release-body: ${message}`);
process.exit(1);
}
function extractSection(notes, name) {
const pattern = new RegExp(
`<!--\\s*${name}\\s*-->([\\s\\S]*?)<!--\\s*/${name}\\s*-->`,
);
const match = notes.match(pattern);
if (!match) {
fail(`missing <!-- ${name} --> section in release notes`);
}
const value = match[1].trim();
if (!value) {
fail(`empty <!-- ${name} --> section in release notes`);
}
return value;
}
function youtubeId(raw) {
const value = raw.trim();
let match = value.match(/[?&]v=([A-Za-z0-9_-]+)/);
if (match) return match[1];
match = value.match(/youtu\.be\/([A-Za-z0-9_-]+)/);
if (match) return match[1];
match = value.match(/embed\/([A-Za-z0-9_-]+)/);
if (match) return match[1];
if (/^[A-Za-z0-9_-]+$/.test(value)) return value;
fail(`could not parse a YouTube video id from "${value}"`);
}
function buildTable(version, mobileVersion) {
const tag = `release-${version}-tag`;
const base = `https://github.com/Termix-SSH/Termix/releases/download/${tag}`;
const mobileBase = `https://github.com/Termix-SSH/Mobile/releases/download/release-${mobileVersion}-tag`;
const win = (file) => `${base}/${file}`;
const linux = (file) => `${base}/${file}`;
const mac = (file) => `${base}/${file}`;
return [
`| Architecture | Windows | Linux | Mac | Android | iOS |`,
`|------------------|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------|---------------------------------------------|-----------------------------------|`,
`| **x86-64 (64-bit)** | [EXE](${win("termix_windows_x64_nsis.exe")}) · [MSI](${win("termix_windows_x64_msi.msi")}) · [Portable](${win("termix_windows_x64_portable.zip")}) | [AppImage](${linux("termix_linux_x64_appimage.AppImage")}) · [DEB](${linux("termix_linux_x64_deb.deb")}) · [Portable](${linux("termix_linux_x64_portable.tar.gz")}) | [DMG](${mac("termix_macos_x64_dmg.dmg")}) | — | — |`,
`| **AArch64 (ARM64)** | — | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
`| **ARMv7 (32-bit)** | — | [AppImage](${linux("termix_linux_armv7l_appimage.AppImage")}) · [DEB](${linux("termix_linux_armv7l_deb.deb")}) · [Portable](${linux("termix_linux_armv7l_portable.tar.gz")}) | — | — | — |`,
`| **x86-32 (32-bit)** | [EXE](${win("termix_windows_ia32_nsis.exe")}) · [MSI](${win("termix_windows_ia32_msi.msi")}) · [Portable](${win("termix_windows_ia32_portable.zip")}) | — | — | — | — |`,
`| **Universal** | [Chocolatey](https://docs.termix.site/install/connector/windows) | [Flatpak](https://docs.termix.site/install/connector/linux) | [DMG](${mac("termix_macos_universal_dmg.dmg")}) · [App Store](https://apps.apple.com/us/app/termix-ssh-companion/id6752672071) · [Homebrew](https://docs.termix.site/install/connector/macos) | — | — |`,
].join("\n");
}
function main() {
const args = parseArgs(process.argv.slice(2));
const version = args.version;
const mobileVersion = args["mobile-version"];
const notesPath = args.notes || "RELEASE_NOTES.md";
if (!version || version === true) fail("--version is required");
if (!mobileVersion || mobileVersion === true)
fail("--mobile-version is required");
const resolvedNotes = path.resolve(notesPath);
if (!fs.existsSync(resolvedNotes)) {
fail(`release notes file not found: ${resolvedNotes}`);
}
const notes = fs.readFileSync(resolvedNotes, "utf8");
const summary = extractSection(notes, "SUMMARY");
const youtube = extractSection(notes, "YOUTUBE");
const updateLog = extractSection(notes, "UPDATE_LOG");
const bugFixes = extractSection(notes, "BUG_FIXES");
const videoId = youtubeId(youtube);
const embed = [
`<a href="https://youtu.be/${videoId}">`,
` <img src="./repo-images/YouTube.png" alt="YouTube" width="500">`,
`</a>`,
].join("\n");
const table = buildTable(version, mobileVersion);
const body = [
summary,
"",
embed,
"",
table,
"",
"Update Log:",
updateLog,
"",
"Bug Fixes:",
bugFixes,
].join("\n");
process.stdout.write(body + "\n");
}
main();
-43
View File
@@ -1,43 +0,0 @@
const { parseDevBranch } = require("./parse-dev-branch.cjs");
function compareSemver(a, b) {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if (pa[i] !== pb[i]) return pa[i] - pb[i];
}
return 0;
}
function latestDevBranch(refs) {
const versions = [];
for (const ref of refs) {
const name = ref.replace(/^refs\/heads\//, "").trim();
if (!name) continue;
try {
versions.push({ name, version: parseDevBranch(name) });
} catch {
// not a dev-X.Y.Z branch, skip it
}
}
if (versions.length === 0) {
throw new Error("no dev-X.Y.Z branches found");
}
versions.sort((a, b) => compareSemver(a.version, b.version));
return versions[versions.length - 1].name;
}
module.exports = { latestDevBranch };
if (require.main === module) {
const input = require("fs").readFileSync(0, "utf8");
const refs = input.split("\n").filter(Boolean);
try {
process.stdout.write(latestDevBranch(refs) + "\n");
} catch (err) {
console.error(`latest-dev-branch: ${err.message}`);
process.exit(1);
}
}
-33
View File
@@ -1,33 +0,0 @@
import { describe, it, expect } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { latestDevBranch } = require("./latest-dev-branch.cjs");
describe("latestDevBranch", () => {
it("picks the highest semver dev branch", () => {
expect(latestDevBranch(["dev-2.3.9", "dev-2.4.0", "dev-1.11.2"])).toBe(
"dev-2.4.0",
);
});
it("compares numerically, not lexically", () => {
expect(latestDevBranch(["dev-2.10.0", "dev-2.9.0"])).toBe("dev-2.10.0");
});
it("ignores main and non-dev branches", () => {
expect(latestDevBranch(["main", "feature/x", "dev-1.0.0"])).toBe(
"dev-1.0.0",
);
});
it("strips refs/heads/ prefixes", () => {
expect(latestDevBranch(["refs/heads/dev-3.0.1"])).toBe("dev-3.0.1");
});
it("throws when there are no dev branches", () => {
expect(() => latestDevBranch(["main", "feature/x"])).toThrow(
/no dev-X\.Y\.Z branches/,
);
});
});
-36
View File
@@ -1,36 +0,0 @@
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
function parseDevBranch(ref) {
if (!ref || typeof ref !== "string") {
throw new Error("a branch ref is required");
}
const name = ref.replace(/^refs\/heads\//, "").trim();
if (!name.startsWith("dev-")) {
throw new Error(
`release must run from a dev branch, got "${name}" (expected dev-X.Y.Z)`,
);
}
const version = name.slice("dev-".length);
if (!SEMVER.test(version)) {
throw new Error(
`branch "${name}" does not contain a valid semver version (got "${version}")`,
);
}
return version;
}
module.exports = { parseDevBranch };
if (require.main === module) {
const ref = process.argv[2] || process.env.GITHUB_REF;
try {
process.stdout.write(parseDevBranch(ref) + "\n");
} catch (err) {
console.error(`parse-dev-branch: ${err.message}`);
process.exit(1);
}
}
-31
View File
@@ -1,31 +0,0 @@
import { describe, it, expect } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { parseDevBranch } = require("./parse-dev-branch.cjs");
describe("parseDevBranch", () => {
it("extracts the version from a dev branch name", () => {
expect(parseDevBranch("dev-2.4.0")).toBe("2.4.0");
});
it("strips a refs/heads/ prefix", () => {
expect(parseDevBranch("refs/heads/dev-1.11.2")).toBe("1.11.2");
});
it("rejects the main branch", () => {
expect(() => parseDevBranch("main")).toThrow(/dev branch/);
});
it("rejects a dev branch without a semver version", () => {
expect(() => parseDevBranch("dev-foo")).toThrow(/valid semver/);
});
it("rejects a partial version", () => {
expect(() => parseDevBranch("dev-2.4")).toThrow(/valid semver/);
});
it("rejects an empty ref", () => {
expect(() => parseDevBranch("")).toThrow();
});
});
+15 -248
View File
@@ -1,7 +1,7 @@
const fs = require("fs");
const path = require("path");
const guacdClientPath = path.join(
const filePath = path.join(
__dirname,
"..",
"node_modules",
@@ -9,263 +9,30 @@ const guacdClientPath = path.join(
"lib",
"GuacdClient.js",
);
const cryptPath = path.join(
__dirname,
"..",
"node_modules",
"guacamole-lite",
"lib",
"Crypt.js",
);
if (!fs.existsSync(guacdClientPath) || !fs.existsSync(cryptPath)) {
if (!fs.existsSync(filePath)) {
console.log("[patch-guacamole-lite] File not found, skipping");
process.exit(0);
}
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
let cryptContent = fs.readFileSync(cryptPath, "utf8");
let content = fs.readFileSync(filePath, "utf8");
// Patch 1: protocol version negotiation.
// guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol
// versions Termix can handle, and conservatively answer future 1.x versions as
// VERSION_1_5_0 so guacd still sees support for `require`/`name` without us
// claiming support for unknown instructions.
const oldVersionBlock =
" if (version === '1_0_0' || version === '1_1_0') {\n" +
" protocolVersion = version;\n" +
" } else {\n" +
" protocolVersion = '1_1_0';\n" +
" }";
const oldPatchedVersionBlock =
" if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {\n" +
" protocolVersion = version;\n" +
" } else {\n" +
" protocolVersion = '1_1_0';\n" +
" }";
const newVersionBlock =
" if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {\n" +
" protocolVersion = version;\n" +
" } else if (/^1_\\d+_0$/.test(version)) {\n" +
" protocolVersion = '1_5_0';\n" +
" } else {\n" +
" protocolVersion = '1_1_0';\n" +
" }";
const oldCheck = "if (version === '1_0_0' || version === '1_1_0') {";
const newCheck =
"if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {";
// Patch 2: timezone instruction must be sent for all protocols >= 1.1.0, not just 1.1.0
const oldTimezone = "if (protocolVersion === '1_1_0') {";
const newTimezone = "if (protocolVersion !== '1_0_0') {";
// Patch 3: send the `name` handshake instruction for protocol >= 1.3.0.
// The Guacamole protocol added the `name` instruction in 1.3.0 (an optional
// human-readable identifier for the joining user). guacd 1.6.0 began requiring
// it during the VNC handshake even when negotiating older protocol versions,
// causing connections to silently drop right after "User joined". See
// Termix-SSH/Support#567 and #734.
const oldConnect =
" this.sendInstruction(['connect'].concat(connectArgs));";
const newConnect =
" if (protocolVersion === '1_3_0' || protocolVersion === '1_5_0') {\n" +
" this.sendInstruction(['name', this.connectionSettings.name || 'guacamole-lite']);\n" +
" }\n" +
"\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 (!guacdClientContent.includes("} else if (/^1_\\d+_0$/.test(version)) {")) {
if (guacdClientContent.includes(oldPatchedVersionBlock)) {
guacdClientContent = guacdClientContent.replace(
oldPatchedVersionBlock,
newVersionBlock,
);
} else if (guacdClientContent.includes(oldVersionBlock)) {
guacdClientContent = guacdClientContent.replace(
oldVersionBlock,
newVersionBlock,
);
} else {
console.log(
"[patch-guacamole-lite] Version check target not found, skipping",
);
process.exit(0);
}
patched = true;
}
if (!guacdClientContent.includes(newTimezone)) {
if (!guacdClientContent.includes(oldTimezone)) {
console.log("[patch-guacamole-lite] Timezone target not found, skipping");
process.exit(0);
}
guacdClientContent = guacdClientContent.replace(oldTimezone, newTimezone);
patched = true;
}
if (!guacdClientContent.includes(newConnect)) {
if (!guacdClientContent.includes(oldConnect)) {
console.log(
"[patch-guacamole-lite] Connect target not found, skipping name patch",
);
process.exit(0);
}
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;
}
if (!patched) {
if (content.includes(newCheck)) {
console.log("[patch-guacamole-lite] Already patched");
process.exit(0);
}
fs.writeFileSync(guacdClientPath, guacdClientContent);
fs.writeFileSync(cryptPath, cryptContent);
if (!content.includes(oldCheck)) {
console.log("[patch-guacamole-lite] Target code not found, skipping");
process.exit(0);
}
content = content.replace(oldCheck, newCheck);
fs.writeFileSync(filePath, content);
console.log(
"[patch-guacamole-lite] Patched protocol VERSION_1_3_0/1_5_0 support, name handshake, required arguments, and UTF-8 token decrypt",
"[patch-guacamole-lite] Patched to support protocol VERSION_1_3_0 and VERSION_1_5_0",
);
-91
View File
@@ -1,91 +0,0 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const GuacdClient = require("../node_modules/guacamole-lite/lib/GuacdClient.js");
type PatchedGuacdClient = {
connectionSettings: Record<string, unknown>;
nextArgumentStreamIndex: number;
sendInstruction: ReturnType<typeof vi.fn>;
sendHandshakeReply: (serverHandshake: string[]) => void;
sendRequiredArguments: (params: string[]) => void;
};
function createPatchedClient(
connectionSettings: Record<string, unknown>,
): PatchedGuacdClient {
return Object.assign(Object.create(GuacdClient.prototype), {
connectionSettings,
logger: { log: vi.fn() },
nextArgumentStreamIndex: 0,
sendInstruction: vi.fn(),
});
}
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'");
});
it("keeps required-argument support when guacd offers a future 1.x protocol", () => {
const client = createPatchedClient({
hostname: "192.0.2.10",
port: 5900,
password: "secret",
width: 1280,
height: 720,
dpi: 96,
});
client.sendHandshakeReply(["VERSION_1_6_0", "hostname", "port"]);
expect(client.sendInstruction).toHaveBeenCalledWith(["timezone"]);
expect(client.sendInstruction).toHaveBeenCalledWith([
"name",
"guacamole-lite",
]);
expect(client.sendInstruction).toHaveBeenCalledWith([
"connect",
"VERSION_1_5_0",
"192.0.2.10",
5900,
]);
});
it("answers required credentials through argument value streams", () => {
const client = createPatchedClient({
username: "",
password: "secret",
});
client.sendRequiredArguments(["username", "password"]);
expect(
client.sendInstruction.mock.calls.map(([instruction]) => instruction),
).toEqual([
["argv", 0, "text/plain", "username"],
["blob", 0, ""],
["end", 0],
["argv", 1, "text/plain", "password"],
["blob", 1, Buffer.from("secret", "utf8").toString("base64")],
["end", 1],
]);
});
});
-85
View File
@@ -1,85 +0,0 @@
const fs = require("node:fs");
const path = require("node:path");
const xtermDir = path.join(
__dirname,
"..",
"node_modules",
"@xterm",
"xterm",
"lib",
);
// Backport the textarea-shrink fix from gmuxapp/xterm.js@6a011cf while
// xtermjs/xterm.js#3600 remains unresolved upstream. Android IMEs can restart
// composition on the previous word and replace it with a shorter value (for
// example, Vietnamese "Hoar" -> "Hỏa"). xterm 6.0 otherwise emits nothing.
const patches = [
{
file: "xterm.mjs",
replacements: [
[
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
],
[
'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
'this._compositionPosition.start=this._textarea.value.length,this._preCompositionValue=this._textarea.value,this._compositionView.textContent=""',
],
[
"let e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0",
"let e={start:this._compositionPosition.start,end:this._compositionPosition.end};const s=this._preCompositionValue;this._isSendingComposition=!0",
],
[
"e.start+=this._dataAlreadySent.length,this._isComposing?i=this._textarea.value.substring(e.start,this._compositionPosition.start):i=this._textarea.value.substring(e.start),i.length>0&&",
"e.start+=this._dataAlreadySent.length;if(this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const t=this._textarea.value;if(t.length<s.length){let e=0;const r=Math.min(t.length,s.length);for(;e<r&&t.charCodeAt(e)===s.charCodeAt(e);)e++;i=b.DEL.repeat(s.length-e)+t.substring(e)}else i=t.substring(e.start)}i.length>0&&",
],
],
},
{
file: "xterm.js",
replacements: [
[
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
],
[
'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
'this._compositionPosition.start=this._textarea.value.length,this._preCompositionValue=this._textarea.value,this._compositionView.textContent=""',
],
[
"const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0",
"const e={start:this._compositionPosition.start,end:this._compositionPosition.end},i=this._preCompositionValue;this._isSendingComposition=!0",
],
[
"e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,this._compositionPosition.start):this._textarea.value.substring(e.start),t.length>0&&",
"e.start+=this._dataAlreadySent.length;this._isComposing?t=this._textarea.value.substring(e.start,this._compositionPosition.start):(()=>{const s=this._textarea.value;if(s.length<i.length){let e=0;const r=Math.min(s.length,i.length);for(;e<r&&s.charCodeAt(e)===i.charCodeAt(e);)e++;t=a.C0.DEL.repeat(i.length-e)+s.substring(e)}else t=s.substring(e.start)})(),t.length>0&&",
],
],
},
];
for (const { file, replacements } of patches) {
const filePath = path.join(xtermDir, file);
if (!fs.existsSync(filePath)) {
throw new Error(`[patch-xterm-android-ime] Missing ${filePath}`);
}
let source = fs.readFileSync(filePath, "utf8");
if (source.includes("_preCompositionValue")) {
console.log(`[patch-xterm-android-ime] ${file} already patched`);
continue;
}
for (const [original, patched] of replacements) {
if (!source.includes(original)) {
throw new Error(
`[patch-xterm-android-ime] Expected source not found in ${file}`,
);
}
source = source.replace(original, patched);
}
fs.writeFileSync(filePath, source);
console.log(`[patch-xterm-android-ime] Patched ${file}`);
}
-121
View File
@@ -1,121 +0,0 @@
const fs = require("fs");
const path = require("path");
function extractYoutubeId(notes) {
const match = notes.match(
/<!--\s*YOUTUBE\s*-->([\s\S]*?)<!--\s*\/YOUTUBE\s*-->/,
);
if (!match || !match[1].trim()) {
throw new Error(
"missing or empty <!-- YOUTUBE --> section in release notes",
);
}
return parseYoutubeId(match[1].trim());
}
function parseYoutubeId(raw) {
const value = raw.trim();
let m = value.match(/[?&]v=([A-Za-z0-9_-]+)/);
if (m) return m[1];
m = value.match(/youtu\.be\/([A-Za-z0-9_-]+)/);
if (m) return m[1];
m = value.match(/embed\/([A-Za-z0-9_-]+)/);
if (m) return m[1];
if (/^[A-Za-z0-9_-]+$/.test(value)) return value;
throw new Error(`could not parse a YouTube video id from "${value}"`);
}
async function getAccessToken({ clientId, clientSecret, refreshToken }) {
const res = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
refresh_token: refreshToken,
grant_type: "refresh_token",
}),
});
if (!res.ok) {
throw new Error(
`token exchange failed (${res.status}): ${await res.text()}`,
);
}
const json = await res.json();
return json.access_token;
}
async function getVideoStatus(accessToken, videoId) {
const res = await fetch(
`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()}`);
const json = await res.json();
return json.items?.[0]?.status?.privacyStatus ?? null;
}
async function setVideoPublic(accessToken, videoId) {
const status = await getVideoStatus(accessToken, videoId);
if (status === "public") {
console.log(`Video ${videoId} is already public, skipping.`);
return;
}
const res = await fetch(
"https://www.googleapis.com/youtube/v3/videos?part=status",
{
method: "PUT",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
id: videoId,
status: { privacyStatus: "public" },
}),
},
);
if (!res.ok) {
throw new Error(
`videos.update failed (${res.status}): ${await res.text()}`,
);
}
return res.json();
}
async function main() {
const clientId = process.env.YOUTUBE_CLIENT_ID;
const clientSecret = process.env.YOUTUBE_CLIENT_SECRET;
const refreshToken = process.env.YOUTUBE_REFRESH_TOKEN;
if (!clientId || !clientSecret || !refreshToken) {
throw new Error(
"YOUTUBE_CLIENT_ID, YOUTUBE_CLIENT_SECRET, and YOUTUBE_REFRESH_TOKEN are required",
);
}
const notesPath = path.resolve(
process.env.RELEASE_NOTES || "RELEASE_NOTES.md",
);
const notes = fs.readFileSync(notesPath, "utf8");
const videoId = extractYoutubeId(notes);
const accessToken = await getAccessToken({
clientId,
clientSecret,
refreshToken,
});
await setVideoPublic(accessToken, videoId);
console.log(`Set YouTube video ${videoId} to public.`);
}
module.exports = { extractYoutubeId, parseYoutubeId };
if (require.main === module) {
main().catch((err) => {
console.error(`publish-youtube: ${err.message}`);
process.exit(1);
});
}
-55
View File
@@ -1,55 +0,0 @@
import { describe, it, expect } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { extractYoutubeId, parseYoutubeId } = require("./publish-youtube.cjs");
describe("parseYoutubeId", () => {
it("parses a youtu.be short link", () => {
expect(parseYoutubeId("https://youtu.be/At8iDk6-Q_s")).toBe("At8iDk6-Q_s");
});
it("parses a watch?v= link", () => {
expect(parseYoutubeId("https://www.youtube.com/watch?v=At8iDk6-Q_s")).toBe(
"At8iDk6-Q_s",
);
});
it("parses an embed link", () => {
expect(parseYoutubeId("https://www.youtube.com/embed/At8iDk6-Q_s")).toBe(
"At8iDk6-Q_s",
);
});
it("accepts a bare id", () => {
expect(parseYoutubeId("At8iDk6-Q_s")).toBe("At8iDk6-Q_s");
});
it("throws on garbage", () => {
expect(() => parseYoutubeId("https://example.com/")).toThrow();
});
});
describe("extractYoutubeId", () => {
it("pulls the id from a release notes YOUTUBE section", () => {
const notes = [
"<!-- SUMMARY -->",
"stuff",
"<!-- /SUMMARY -->",
"<!-- YOUTUBE -->",
"https://youtu.be/At8iDk6-Q_s",
"<!-- /YOUTUBE -->",
].join("\n");
expect(extractYoutubeId(notes)).toBe("At8iDk6-Q_s");
});
it("throws when the YOUTUBE section is missing", () => {
expect(() => extractYoutubeId("no section here")).toThrow(/YOUTUBE/);
});
it("throws when the YOUTUBE section is empty", () => {
expect(() =>
extractYoutubeId("<!-- YOUTUBE -->\n\n<!-- /YOUTUBE -->"),
).toThrow(/empty/);
});
});
-79
View File
@@ -1,79 +0,0 @@
const fs = require("fs");
const path = require("path");
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
function readJsonWithTrailingNewline(filePath) {
const raw = fs.readFileSync(filePath, "utf8");
return { data: JSON.parse(raw), hadTrailingNewline: raw.endsWith("\n") };
}
function writeJson(filePath, data, hadTrailingNewline) {
const serialized = JSON.stringify(data, null, 2);
fs.writeFileSync(
filePath,
hadTrailingNewline ? serialized + "\n" : serialized,
);
}
function syncVersion(version, options = {}) {
if (!SEMVER.test(version || "")) {
throw new Error(`invalid version "${version}" (expected X.Y.Z)`);
}
const root = options.root || path.join(__dirname, "..");
const pkgPath = path.join(root, "package.json");
const lockPath = path.join(root, "package-lock.json");
const changed = [];
const pkg = readJsonWithTrailingNewline(pkgPath);
if (pkg.data.version !== version) {
pkg.data.version = version;
writeJson(pkgPath, pkg.data, pkg.hadTrailingNewline);
changed.push("package.json");
}
if (fs.existsSync(lockPath)) {
const lock = readJsonWithTrailingNewline(lockPath);
let lockChanged = false;
if (lock.data.version !== version) {
lock.data.version = version;
lockChanged = true;
}
if (lock.data.packages && lock.data.packages[""]) {
if (lock.data.packages[""].version !== version) {
lock.data.packages[""].version = version;
lockChanged = true;
}
}
if (lockChanged) {
writeJson(lockPath, lock.data, lock.hadTrailingNewline);
changed.push("package-lock.json");
}
}
return changed;
}
module.exports = { syncVersion };
if (require.main === module) {
const args = process.argv.slice(2);
const idx = args.indexOf("--version");
const version = idx !== -1 ? args[idx + 1] : undefined;
try {
const changed = syncVersion(version);
if (changed.length === 0) {
console.log(`sync-version: already at ${version}, no change`);
} else {
console.log(`sync-version: set ${version} in ${changed.join(", ")}`);
}
} catch (err) {
console.error(`sync-version: ${err.message}`);
process.exit(1);
}
}
-80
View File
@@ -1,80 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createRequire } from "module";
import fs from "fs";
import os from "os";
import path from "path";
const require = createRequire(import.meta.url);
const { syncVersion } = require("./sync-version.cjs");
let root: string;
function pkg() {
return JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
}
function lock() {
return JSON.parse(
fs.readFileSync(path.join(root, "package-lock.json"), "utf8"),
);
}
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "sync-version-"));
fs.writeFileSync(
path.join(root, "package.json"),
JSON.stringify({ name: "termix", version: "2.3.2" }, null, 2) + "\n",
);
fs.writeFileSync(
path.join(root, "package-lock.json"),
JSON.stringify(
{
name: "termix",
version: "2.3.2",
lockfileVersion: 3,
packages: { "": { name: "termix", version: "2.3.2" } },
},
null,
2,
) + "\n",
);
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});
describe("syncVersion", () => {
it("updates package.json and both package-lock version fields", () => {
const changed = syncVersion("2.4.0", { root });
expect(changed).toEqual(["package.json", "package-lock.json"]);
expect(pkg().version).toBe("2.4.0");
expect(lock().version).toBe("2.4.0");
expect(lock().packages[""].version).toBe("2.4.0");
});
it("is idempotent when already at the target version", () => {
syncVersion("2.4.0", { root });
const changed = syncVersion("2.4.0", { root });
expect(changed).toEqual([]);
});
it("preserves the trailing newline", () => {
syncVersion("2.4.0", { root });
expect(fs.readFileSync(path.join(root, "package.json"), "utf8")).toMatch(
/\}\n$/,
);
});
it("rejects an invalid version", () => {
expect(() => syncVersion("2.4", { root })).toThrow(/invalid version/);
});
it("works when only the lock root version is stale", () => {
fs.writeFileSync(
path.join(root, "package.json"),
JSON.stringify({ name: "termix", version: "2.4.0" }, null, 2) + "\n",
);
const changed = syncVersion("2.4.0", { root });
expect(changed).toEqual(["package-lock.json"]);
});
});
+240 -40
View File
@@ -1,15 +1,18 @@
import express from "express";
import cookieParser from "cookie-parser";
import { createCorsMiddleware } from "./utils/cors-config.js";
import { getDb, DatabaseSaveTrigger } from "./database/db/index.js";
import {
recentActivity,
hosts,
hostAccess,
dashboardPreferences,
} from "./database/db/schema.js";
import { eq, and, desc, sql, inArray } from "drizzle-orm";
import { dashboardLogger } from "./utils/logger.js";
import { SimpleDBOps } from "./utils/simple-db-ops.js";
import { AuthManager } from "./utils/auth-manager.js";
import type { AuthenticatedRequest } from "../types/index.js";
import { dashboardServiceLinksRouter } from "./database/routes/dashboard-service-links-routes.js";
import { createCurrentHostResolutionRepository } from "./database/repositories/current-host-resolution-repository.js";
import { createCurrentRbacAccessRepository } from "./database/repositories/current-rbac-access-repository.js";
import { createCurrentRecentActivityRepository } from "./database/repositories/current-recent-activity-repository.js";
import { createCurrentRoleRepository } from "./database/repositories/current-role-repository.js";
import { DataCrypto } from "./utils/data-crypto.js";
const app = express();
const authManager = AuthManager.getInstance();
@@ -19,10 +22,6 @@ const serverStartTime = Date.now();
const activityRateLimiter = new Map<string, number>();
const RATE_LIMIT_MS = 1000;
function isUserDataUnlocked(userId: string): boolean {
return DataCrypto.getUserDataKey(userId) !== null;
}
app.use(createCorsMiddleware());
app.use(cookieParser());
app.use(express.json({ limit: "1mb" }));
@@ -103,7 +102,7 @@ app.get("/activity/recent", async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
if (!isUserDataUnlocked(userId)) {
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
@@ -112,8 +111,16 @@ app.get("/activity/recent", async (req, res) => {
const limit = Number(req.query.limit) || 20;
const activities =
await createCurrentRecentActivityRepository().listByUserId(userId, limit);
const activities = await SimpleDBOps.select(
getDb()
.select()
.from(recentActivity)
.where(eq(recentActivity.userId, userId))
.orderBy(desc(recentActivity.timestamp))
.limit(limit),
"recent_activity",
userId,
);
res.json(activities);
} catch (err) {
@@ -160,7 +167,7 @@ app.post("/activity/log", async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
if (!isUserDataUnlocked(userId)) {
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
@@ -215,45 +222,78 @@ app.post("/activity/log", async (req, res) => {
entriesToDelete.forEach((key) => activityRateLimiter.delete(key));
}
const isOwnedHost =
await createCurrentHostResolutionRepository().isHostOwnedByUser(
hostId,
userId,
);
const ownedHosts = await SimpleDBOps.select(
getDb()
.select()
.from(hosts)
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
"ssh_data",
userId,
);
if (!isOwnedHost) {
const roleIds =
await createCurrentRoleRepository().listUserRoleIds(userId);
const sharedHosts =
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
userId,
roleIds,
if (ownedHosts.length === 0) {
const sharedHosts = await getDb()
.select()
.from(hostAccess)
.where(
and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)),
);
const hasSharedAccess = sharedHosts.some(
(access) => access.hostId === hostId,
);
if (!hasSharedAccess) {
if (sharedHosts.length === 0) {
return res
.status(404)
.json({ error: "Host not found or access denied" });
}
}
const result = await createCurrentRecentActivityRepository().create({
const result = (await SimpleDBOps.insert(
recentActivity,
"recent_activity",
{
userId,
type,
hostId,
hostName,
},
userId,
type,
hostId,
hostName,
});
)) as unknown as { id: number };
// Best-effort trim of old activity entries; failures here should not
// cause the primary /activity/log request to 500.
try {
await createCurrentRecentActivityRepository().trimUserActivity(
const allActivities = await SimpleDBOps.select<{
id: number;
timestamp: string;
}>(
getDb()
.select({
id: recentActivity.id,
timestamp: recentActivity.timestamp,
})
.from(recentActivity)
.where(eq(recentActivity.userId, userId))
.orderBy(desc(recentActivity.timestamp)),
"recent_activity",
userId,
100,
);
if (allActivities.length > 100) {
const idsToDelete = allActivities
.slice(100)
.map((a) => a.id)
.filter((id) => typeof id === "number");
if (idsToDelete.length > 0) {
await SimpleDBOps.delete(
recentActivity,
"recent_activity",
and(
eq(recentActivity.userId, userId),
inArray(recentActivity.id, idsToDelete),
),
);
}
}
} catch (trimErr) {
dashboardLogger.warn("Failed to trim recent_activity (non-fatal)", {
operation: "trim_recent_activity",
@@ -289,14 +329,18 @@ app.delete("/activity/reset", async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
if (!isUserDataUnlocked(userId)) {
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await SimpleDBOps.delete(
recentActivity,
"recent_activity",
eq(recentActivity.userId, userId),
);
dashboardLogger.success("Recent activity cleared", {
operation: "reset_recent_activity",
@@ -310,7 +354,163 @@ app.delete("/activity/reset", async (req, res) => {
}
});
app.use("/service-links", dashboardServiceLinksRouter);
/**
* @openapi
* /dashboard/preferences:
* get:
* summary: Get dashboard layout preferences
* description: Returns the user's customized dashboard layout settings. If no preferences exist, returns default layout.
* tags:
* - Dashboard
* responses:
* 200:
* description: Dashboard preferences retrieved
* content:
* application/json:
* schema:
* type: object
* properties:
* cards:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* enabled:
* type: boolean
* order:
* type: integer
* 401:
* description: Session expired
* 500:
* description: Failed to get preferences
*/
app.get("/dashboard/preferences", async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
const preferences = await getDb()
.select()
.from(dashboardPreferences)
.where(eq(dashboardPreferences.userId, userId));
if (preferences.length === 0) {
const defaultLayout = {
cards: [
{ id: "server_overview", enabled: true, order: 1 },
{ id: "recent_activity", enabled: true, order: 2 },
{ id: "network_graph", enabled: false, order: 3 },
{ id: "quick_actions", enabled: true, order: 4 },
{ id: "server_stats", enabled: true, order: 5 },
],
};
return res.json(defaultLayout);
}
const layout = JSON.parse(preferences[0].layout as string);
res.json(layout);
} catch (err) {
dashboardLogger.error("Failed to get dashboard preferences", err);
res.status(500).json({ error: "Failed to get dashboard preferences" });
}
});
/**
* @openapi
* /dashboard/preferences:
* post:
* summary: Save dashboard layout preferences
* description: Saves or updates the user's customized dashboard layout settings.
* tags:
* - Dashboard
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* cards:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* enabled:
* type: boolean
* order:
* type: integer
* responses:
* 200:
* description: Preferences saved successfully
* 400:
* description: Invalid request body
* 401:
* description: Session expired
* 500:
* description: Failed to save preferences
*/
app.post("/dashboard/preferences", async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
const { cards, mainWidthPct } = req.body;
if (!cards || !Array.isArray(cards)) {
return res.status(400).json({
error: "Invalid request body. Expected { cards: Array }",
});
}
const layoutObj: Record<string, unknown> = { cards };
if (typeof mainWidthPct === "number") {
layoutObj.mainWidthPct = mainWidthPct;
}
const layout = JSON.stringify(layoutObj);
const existing = await getDb()
.select()
.from(dashboardPreferences)
.where(eq(dashboardPreferences.userId, userId));
if (existing.length > 0) {
await getDb()
.update(dashboardPreferences)
.set({ layout, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(eq(dashboardPreferences.userId, userId));
} else {
await getDb().insert(dashboardPreferences).values({ userId, layout });
}
await DatabaseSaveTrigger.triggerSave("dashboard_preferences_updated");
dashboardLogger.success("Dashboard preferences saved", {
operation: "save_dashboard_preferences",
userId,
});
res.json({ success: true, message: "Dashboard preferences saved" });
} catch (err) {
dashboardLogger.error("Failed to save dashboard preferences", err);
res.status(500).json({ error: "Failed to save dashboard preferences" });
}
});
const PORT = 30006;
app.listen(PORT, async () => {
+393 -382
View File
@@ -10,18 +10,9 @@ import credentialsRoutes from "./routes/credentials.js";
import snippetsRoutes from "./routes/snippets.js";
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
import terminalRoutes from "./routes/terminal.js";
import sessionLogRoutes from "./routes/session-log-routes.js";
import guacamoleRoutes from "../guacamole/routes.js";
import networkTopologyRoutes from "./routes/network-topology.js";
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";
@@ -34,24 +25,27 @@ import { DatabaseFileEncryption } from "../utils/database-file-encryption.js";
import { DatabaseMigration } from "../utils/database-migration.js";
import { UserDataExport } from "../utils/user-data-export.js";
import { AutoSSLSetup } from "../utils/auto-ssl-setup.js";
import { createCurrentCredentialRepository } from "./repositories/current-credential-repository.js";
import { createCurrentDismissedAlertRepository } from "./repositories/current-dismissed-alert-repository.js";
import { createCurrentFileManagerBookmarkRepository } from "./repositories/current-file-manager-bookmark-repository.js";
import { createCurrentHostRepository } from "./repositories/current-host-repository.js";
import { createCurrentSettingsRepository } from "./repositories/current-settings-repository.js";
import { createCurrentSshCredentialUsageRepository } from "./repositories/current-ssh-credential-usage-repository.js";
import { createCurrentUserRepository } from "./repositories/current-user-repository.js";
import { getRepositoryRolloutStatus } from "./repositories/repository-rollout.js";
import { withCurrentSqliteForeignKeysDisabled } from "./runtime/sqlite-foreign-key-boundary.js";
import { eq, and } from "drizzle-orm";
import { parseUserAgent } from "../utils/user-agent-parser.js";
import { getProxyAgent } from "../utils/proxy-agent.js";
import {
users,
hosts,
sshCredentials,
fileManagerRecent,
fileManagerPinned,
fileManagerShortcuts,
dismissedAlerts,
sshCredentialUsage,
settings,
} from "./db/schema.js";
import type {
CacheEntry,
GitHubRelease,
GitHubAPIResponse,
AuthenticatedRequest,
} from "../../types/index.js";
import { DatabaseSaveTrigger } from "./db/index.js";
import { getDb, DatabaseSaveTrigger } from "./db/index.js";
import Database from "better-sqlite3";
import { fileURLToPath } from "url";
@@ -67,45 +61,6 @@ const authenticateJWT = authManager.createAuthMiddleware();
const requireAdmin = authManager.createAdminMiddleware();
app.use(createCorsMiddleware());
type SettingData = {
key: string;
value: string;
};
function shouldExportSetting(key: string): boolean {
return !key.startsWith("reset_code_") && !key.startsWith("temp_reset_token_");
}
async function getExportableSettings(): Promise<SettingData[]> {
const settingsRows = await createCurrentSettingsRepository().listAll();
return settingsRows.filter((setting) => shouldExportSetting(setting.key));
}
function writeSettingsToExportDatabase(
exportDb: Database.Database,
settingsRows: SettingData[],
): void {
const insertSetting = exportDb.prepare(`
INSERT INTO settings (key, value)
VALUES (?, ?)
`);
for (const setting of settingsRows) {
insertSetting.run(setting.key, setting.value);
}
}
function readImportedSettings(importDb: Database.Database): SettingData[] {
return importDb
.prepare("SELECT key, value FROM settings")
.all() as SettingData[];
}
async function upsertImportedSetting(setting: SettingData): Promise<void> {
await createCurrentSettingsRepository().upsert(setting.key, setting.value);
}
const uploadsDir = path.join(process.env.DATA_DIR || "./db/data", "uploads");
const storage = multer.diskStorage({
@@ -657,13 +612,12 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const deviceInfo = parseUserAgent(req);
const userRepository = createCurrentUserRepository();
const user = await userRepository.findById(userId);
if (!user) {
const user = await getDb().select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
return res.status(404).json({ error: "User not found" });
}
const isOidcUser = !!user.isOidc;
const isOidcUser = !!user[0].isOidc;
if (!DataCrypto.getUserDataKey(userId)) {
if (isOidcUser) {
@@ -853,16 +807,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE transfer_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
source_host_id INTEGER NOT NULL,
dest_host_id INTEGER NOT NULL,
dest_path TEXT NOT NULL,
dest_path_label TEXT NOT NULL,
last_used TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE dismissed_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -904,14 +848,22 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
userRecord.totpBackupCodes || null,
);
const sshHosts =
await createCurrentHostRepository().listDecryptedByUserId(userId);
const sshHosts = await getDb()
.select()
.from(hosts)
.where(eq(hosts.userId, userId));
const insertHost = exportDb.prepare(`
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_docker, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, stats_config, docker_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, security, ignore_cert, guacamole_config, mac_address, port_knock_sequence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const decrypted of sshHosts) {
for (const host of sshHosts) {
const decrypted = DataCrypto.decryptRecord(
"ssh_data",
host,
userId,
userDataKey,
);
insertHost.run(
decrypted.id,
decrypted.userId,
@@ -969,14 +921,22 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
);
}
const credentials =
await createCurrentCredentialRepository().listDecryptedByUserId(userId);
const credentials = await getDb()
.select()
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId));
const insertCred = exportDb.prepare(`
INSERT INTO ssh_credentials (id, user_id, name, description, folder, tags, auth_type, username, password, key, private_key, public_key, key_password, key_type, detected_key_type, usage_count, last_used, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const decrypted of credentials) {
for (const cred of credentials) {
const decrypted = DataCrypto.decryptRecord(
"ssh_credentials",
cred,
userId,
userDataKey,
);
insertCred.run(
decrypted.id,
decrypted.userId,
@@ -1000,12 +960,19 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
);
}
const fileManagerRepository =
createCurrentFileManagerBookmarkRepository();
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([
fileManagerRepository.listRecentByUserId(userId),
fileManagerRepository.listPinnedByUserId(userId),
fileManagerRepository.listShortcutsByUserId(userId),
getDb()
.select()
.from(fileManagerRecent)
.where(eq(fileManagerRecent.userId, userId)),
getDb()
.select()
.from(fileManagerPinned)
.where(eq(fileManagerPinned.userId, userId)),
getDb()
.select()
.from(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.userId, userId)),
]);
const insertRecent = exportDb.prepare(`
@@ -1053,8 +1020,10 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
);
}
const dismissedAlertRepository = createCurrentDismissedAlertRepository();
const alerts = await dismissedAlertRepository.listByUserId(userId);
const alerts = await getDb()
.select()
.from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
const insertAlert = exportDb.prepare(`
INSERT INTO dismissed_alerts (id, user_id, alert_id, dismissed_at)
VALUES (?, ?, ?, ?)
@@ -1068,9 +1037,10 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
);
}
const sshCredentialUsageRepository =
createCurrentSshCredentialUsageRepository();
const usage = await sshCredentialUsageRepository.listByUserId(userId);
const usage = await getDb()
.select()
.from(sshCredentialUsage)
.where(eq(sshCredentialUsage.userId, userId));
const insertUsage = exportDb.prepare(`
INSERT INTO ssh_credential_usage (id, credential_id, host_id, user_id, used_at)
VALUES (?, ?, ?, ?, ?)
@@ -1085,7 +1055,14 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
);
}
writeSettingsToExportDatabase(exportDb, await getExportableSettings());
const settingsData = await getDb().select().from(settings);
const insertSetting = exportDb.prepare(`
INSERT INTO settings (key, value)
VALUES (?, ?)
`);
for (const setting of settingsData) {
insertSetting.run(setting.key, setting.value);
}
} finally {
exportDb.close();
}
@@ -1180,16 +1157,19 @@ app.post(
}
const userId = (req as AuthenticatedRequest).userId;
const mainDb = getDb();
const deviceInfo = parseUserAgent(req);
const userRepository = createCurrentUserRepository();
const userRecord = await userRepository.findById(userId);
const userRecords = await mainDb
.select()
.from(users)
.where(eq(users.id, userId));
if (!userRecord) {
if (!userRecords || userRecords.length === 0) {
return res.status(404).json({ error: "User not found" });
}
const isOidcUser = !!userRecord.isOidc;
const isOidcUser = !!userRecords[0].isOidc;
if (!DataCrypto.getUserDataKey(userId)) {
if (isOidcUser) {
@@ -1271,287 +1251,332 @@ app.post(
};
try {
await withCurrentSqliteForeignKeysDisabled(async () => {
try {
const importedHosts = importDb
.prepare("SELECT * FROM ssh_data")
.all();
for (const host of importedHosts) {
try {
const hostRepository = createCurrentHostRepository();
const exists = await hostRepository.existsForImportIdentity(
userId,
host.ip,
host.port,
host.username,
);
if (exists) {
result.summary.skippedItems++;
continue;
}
const hostData = {
userId: userId,
name: host.name,
ip: host.ip,
port: host.port,
username: host.username,
folder: host.folder,
tags: host.tags,
pin: Boolean(host.pin),
authType: host.auth_type,
forceKeyboardInteractive: host.force_keyboard_interactive,
password: host.password,
key: host.key,
keyPassword: host.key_password,
keyType: host.key_type,
sudoPassword: host.sudo_password,
autostartPassword: host.autostart_password,
autostartKey: host.autostart_key,
autostartKeyPassword: host.autostart_key_password,
credentialId: host.credential_id || null,
overrideCredentialUsername: Boolean(
host.override_credential_username,
),
enableTerminal: Boolean(host.enable_terminal),
enableTunnel: Boolean(host.enable_tunnel),
tunnelConnections: host.tunnel_connections,
jumpHosts: host.jump_hosts,
enableFileManager: Boolean(host.enable_file_manager),
enableDocker: Boolean(host.enable_docker),
showTerminalInSidebar: Boolean(host.show_terminal_in_sidebar),
showFileManagerInSidebar: Boolean(
host.show_file_manager_in_sidebar,
),
showTunnelInSidebar: Boolean(host.show_tunnel_in_sidebar),
showDockerInSidebar: Boolean(host.show_docker_in_sidebar),
showServerStatsInSidebar: Boolean(
host.show_server_stats_in_sidebar,
),
defaultPath: host.default_path,
statsConfig: host.stats_config,
terminalConfig: host.terminal_config,
quickActions: host.quick_actions,
notes: host.notes,
useSocks5: Boolean(host.use_socks5),
socks5Host: host.socks5_host,
socks5Port: host.socks5_port,
socks5Username: host.socks5_username,
socks5Password: host.socks5_password,
socks5ProxyChain: host.socks5_proxy_chain,
createdAt: host.created_at || new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await hostRepository.createEncryptedForUser(userId, hostData);
result.summary.sshHostsImported++;
} catch (hostError) {
result.summary.errors.push(
`SSH host import error: ${hostError.message}`,
);
}
}
} catch {
apiLogger.info("ssh_data table not found in import file, skipping");
}
try {
const importedCreds = importDb
.prepare("SELECT * FROM ssh_credentials")
.all();
for (const cred of importedCreds) {
try {
const credentialRepository =
createCurrentCredentialRepository();
const exists =
await credentialRepository.existsForImportIdentity(
userId,
cred.name,
cred.username,
);
if (exists) {
result.summary.skippedItems++;
continue;
}
const credData = {
userId: userId,
name: cred.name,
description: cred.description,
folder: cred.folder,
tags: cred.tags,
authType: cred.auth_type,
username: cred.username,
password: cred.password,
key: cred.key,
privateKey: cred.private_key,
publicKey: cred.public_key,
keyPassword: cred.key_password,
keyType: cred.key_type,
detectedKeyType: cred.detected_key_type,
usageCount: cred.usage_count || 0,
lastUsed: cred.last_used,
createdAt: cred.created_at || new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await credentialRepository.createEncryptedForUser(
userId,
credData,
);
result.summary.sshCredentialsImported++;
} catch (credError) {
result.summary.errors.push(
`SSH credential import error: ${credError.message}`,
);
}
}
} catch {
apiLogger.info(
"ssh_credentials table not found in import file, skipping",
);
}
const fileManagerTables = [
{
table: "file_manager_recent",
key: "fileManagerItemsImported",
},
{
table: "file_manager_pinned",
key: "fileManagerItemsImported",
},
{
table: "file_manager_shortcuts",
key: "fileManagerItemsImported",
},
];
const fileManagerRepository =
createCurrentFileManagerBookmarkRepository();
for (const { table, key } of fileManagerTables) {
mainDb.$client.exec("PRAGMA foreign_keys = OFF");
try {
const importedHosts = importDb
.prepare("SELECT * FROM ssh_data")
.all();
for (const host of importedHosts) {
try {
const importedItems = importDb
.prepare(`SELECT * FROM ${table}`)
.all();
for (const item of importedItems) {
try {
const bookmark = {
hostId: item.host_id,
name: item.name,
path: item.path,
};
const created =
table === "file_manager_recent"
? await fileManagerRepository.createRecentForImport(
userId,
bookmark,
item.last_opened,
)
: table === "file_manager_pinned"
? await fileManagerRepository.createPinnedForImport(
userId,
bookmark,
item.pinned_at,
)
: await fileManagerRepository.createShortcutForImport(
userId,
bookmark,
item.created_at,
);
if (created) {
result.summary[key]++;
} else {
result.summary.skippedItems++;
}
} catch (itemError) {
result.summary.errors.push(
`${table} import error: ${itemError.message}`,
);
}
}
} catch {
apiLogger.info(
`${table} table not found in import file, skipping`,
);
}
}
const dismissedAlertRepository =
createCurrentDismissedAlertRepository();
try {
const importedAlerts = importDb
.prepare("SELECT * FROM dismissed_alerts")
.all();
for (const alert of importedAlerts) {
try {
const created = await dismissedAlertRepository.createForImport(
userId,
alert.alert_id,
alert.dismissed_at,
);
if (created) {
result.summary.dismissedAlertsImported++;
} else {
result.summary.skippedItems++;
}
} catch (alertError) {
result.summary.errors.push(
`Dismissed alert import error: ${alertError.message}`,
const existing = await mainDb
.select()
.from(hosts)
.where(
and(
eq(hosts.userId, userId),
eq(hosts.ip, host.ip),
eq(hosts.port, host.port),
eq(hosts.username, host.username),
),
);
if (existing.length > 0) {
result.summary.skippedItems++;
continue;
}
}
} catch {
apiLogger.info(
"dismissed_alerts table not found in import file, skipping",
);
}
const targetUser = await userRepository.findById(userId);
if (targetUser?.isAdmin) {
try {
const importedSettings = readImportedSettings(importDb);
for (const setting of importedSettings) {
try {
await upsertImportedSetting(setting);
result.summary.settingsImported++;
} catch (settingError) {
result.summary.errors.push(
`Setting import error (${setting.key}): ${settingError.message}`,
);
}
}
} catch {
apiLogger.info(
"settings table not found in import file, skipping",
);
}
} else {
apiLogger.info(
"Settings import skipped - only admin users can import settings",
);
}
const hostData = {
userId: userId,
name: host.name,
ip: host.ip,
port: host.port,
username: host.username,
folder: host.folder,
tags: host.tags,
pin: Boolean(host.pin),
authType: host.auth_type,
forceKeyboardInteractive: host.force_keyboard_interactive,
password: host.password,
key: host.key,
keyPassword: host.key_password,
keyType: host.key_type,
sudoPassword: host.sudo_password,
autostartPassword: host.autostart_password,
autostartKey: host.autostart_key,
autostartKeyPassword: host.autostart_key_password,
credentialId: host.credential_id || null,
overrideCredentialUsername: Boolean(
host.override_credential_username,
),
enableTerminal: Boolean(host.enable_terminal),
enableTunnel: Boolean(host.enable_tunnel),
tunnelConnections: host.tunnel_connections,
jumpHosts: host.jump_hosts,
enableFileManager: Boolean(host.enable_file_manager),
enableDocker: Boolean(host.enable_docker),
showTerminalInSidebar: Boolean(host.show_terminal_in_sidebar),
showFileManagerInSidebar: Boolean(
host.show_file_manager_in_sidebar,
),
showTunnelInSidebar: Boolean(host.show_tunnel_in_sidebar),
showDockerInSidebar: Boolean(host.show_docker_in_sidebar),
showServerStatsInSidebar: Boolean(
host.show_server_stats_in_sidebar,
),
defaultPath: host.default_path,
statsConfig: host.stats_config,
terminalConfig: host.terminal_config,
quickActions: host.quick_actions,
notes: host.notes,
useSocks5: Boolean(host.use_socks5),
socks5Host: host.socks5_host,
socks5Port: host.socks5_port,
socks5Username: host.socks5_username,
socks5Password: host.socks5_password,
socks5ProxyChain: host.socks5_proxy_chain,
createdAt: host.created_at || new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
result.success = true;
try {
await DatabaseSaveTrigger.forceSave("database_import");
} catch (saveError) {
apiLogger.error(
"Failed to persist imported data to disk",
saveError,
{
operation: "import_force_save_failed",
const encrypted = DataCrypto.encryptRecord(
"ssh_data",
hostData,
userId,
},
);
userDataKey,
);
await mainDb.insert(hosts).values(encrypted);
result.summary.sshHostsImported++;
} catch (hostError) {
result.summary.errors.push(
`SSH host import error: ${hostError.message}`,
);
}
}
});
} catch {
apiLogger.info("ssh_data table not found in import file, skipping");
}
try {
const importedCreds = importDb
.prepare("SELECT * FROM ssh_credentials")
.all();
for (const cred of importedCreds) {
try {
const existing = await mainDb
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.userId, userId),
eq(sshCredentials.name, cred.name),
eq(sshCredentials.username, cred.username),
),
);
if (existing.length > 0) {
result.summary.skippedItems++;
continue;
}
const credData = {
userId: userId,
name: cred.name,
description: cred.description,
folder: cred.folder,
tags: cred.tags,
authType: cred.auth_type,
username: cred.username,
password: cred.password,
key: cred.key,
privateKey: cred.private_key,
publicKey: cred.public_key,
keyPassword: cred.key_password,
keyType: cred.key_type,
detectedKeyType: cred.detected_key_type,
usageCount: cred.usage_count || 0,
lastUsed: cred.last_used,
createdAt: cred.created_at || new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const encrypted = DataCrypto.encryptRecord(
"ssh_credentials",
credData,
userId,
userDataKey,
);
await mainDb.insert(sshCredentials).values(encrypted);
result.summary.sshCredentialsImported++;
} catch (credError) {
result.summary.errors.push(
`SSH credential import error: ${credError.message}`,
);
}
}
} catch {
apiLogger.info(
"ssh_credentials table not found in import file, skipping",
);
}
const fileManagerTables = [
{
table: "file_manager_recent",
schema: fileManagerRecent,
key: "fileManagerItemsImported",
},
{
table: "file_manager_pinned",
schema: fileManagerPinned,
key: "fileManagerItemsImported",
},
{
table: "file_manager_shortcuts",
schema: fileManagerShortcuts,
key: "fileManagerItemsImported",
},
];
for (const { table, schema, key } of fileManagerTables) {
try {
const importedItems = importDb
.prepare(`SELECT * FROM ${table}`)
.all();
for (const item of importedItems) {
try {
const existing = await mainDb
.select()
.from(schema)
.where(
and(
eq(schema.userId, userId),
eq(schema.path, item.path),
eq(schema.name, item.name),
),
);
if (existing.length > 0) {
result.summary.skippedItems++;
continue;
}
const itemData = {
userId: userId,
hostId: item.host_id,
name: item.name,
path: item.path,
...(table === "file_manager_recent" && {
lastOpened: item.last_opened,
}),
...(table === "file_manager_pinned" && {
pinnedAt: item.pinned_at,
}),
...(table === "file_manager_shortcuts" && {
createdAt: item.created_at,
}),
};
await mainDb.insert(schema).values(itemData);
result.summary[key]++;
} catch (itemError) {
result.summary.errors.push(
`${table} import error: ${itemError.message}`,
);
}
}
} catch {
apiLogger.info(`${table} table not found in import file, skipping`);
}
}
try {
const importedAlerts = importDb
.prepare("SELECT * FROM dismissed_alerts")
.all();
for (const alert of importedAlerts) {
try {
const existing = await mainDb
.select()
.from(dismissedAlerts)
.where(
and(
eq(dismissedAlerts.userId, userId),
eq(dismissedAlerts.alertId, alert.alert_id),
),
);
if (existing.length > 0) {
result.summary.skippedItems++;
continue;
}
await mainDb.insert(dismissedAlerts).values({
userId: userId,
alertId: alert.alert_id,
dismissedAt: alert.dismissed_at || new Date().toISOString(),
});
result.summary.dismissedAlertsImported++;
} catch (alertError) {
result.summary.errors.push(
`Dismissed alert import error: ${alertError.message}`,
);
}
}
} catch {
apiLogger.info(
"dismissed_alerts table not found in import file, skipping",
);
}
const targetUser = await mainDb
.select()
.from(users)
.where(eq(users.id, userId));
if (targetUser.length > 0 && targetUser[0].isAdmin) {
try {
const importedSettings = importDb
.prepare("SELECT * FROM settings")
.all();
for (const setting of importedSettings) {
try {
const existing = await mainDb
.select()
.from(settings)
.where(eq(settings.key, setting.key));
if (existing.length > 0) {
await mainDb
.update(settings)
.set({ value: setting.value })
.where(eq(settings.key, setting.key));
result.summary.settingsImported++;
} else {
await mainDb.insert(settings).values({
key: setting.key,
value: setting.value,
});
result.summary.settingsImported++;
}
} catch (settingError) {
result.summary.errors.push(
`Setting import error (${setting.key}): ${settingError.message}`,
);
}
}
} catch {
apiLogger.info("settings table not found in import file, skipping");
}
} else {
apiLogger.info(
"Settings import skipped - only admin users can import settings",
);
}
mainDb.$client.exec("PRAGMA foreign_keys = ON");
result.success = true;
try {
await DatabaseSaveTrigger.forceSave("database_import");
} catch (saveError) {
apiLogger.error(
"Failed to persist imported data to disk",
saveError,
{
operation: "import_force_save_failed",
userId,
},
);
}
} finally {
if (importDb) {
importDb.close();
@@ -1734,18 +1759,9 @@ app.use("/credentials", credentialsRoutes);
app.use("/snippets", snippetsRoutes);
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
app.use("/terminal", terminalRoutes);
app.use("/session_logs", sessionLogRoutes);
app.use("/guacamole", guacamoleRoutes);
app.use("/network-topology", networkTopologyRoutes);
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"),
@@ -1788,11 +1804,7 @@ if (frontendDist) {
);
app.use((req, res, next) => {
if (
req.method === "GET" &&
req.accepts("html") &&
!req.headers.authorization
) {
if (req.method === "GET" && req.accepts("html")) {
res.setHeader(
"Cache-Control",
"no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
@@ -1895,7 +1907,6 @@ app.get(
res.json({
migrationStatus: status,
repositoryRollout: getRepositoryRolloutStatus(),
files: {
unencryptedDbSize: unencryptedSize,
encryptedDbSize: encryptedSize,
File diff suppressed because it is too large Load Diff
+16 -467
View File
@@ -1,4 +1,4 @@
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
export const users = sqliteTable("users", {
@@ -9,7 +9,6 @@ export const users = sqliteTable("users", {
isOidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false),
oidcIdentifier: text("oidc_identifier"),
ssoProviderId: integer("sso_provider_id"),
clientId: text("client_id"),
clientSecret: text("client_secret"),
issuerUrl: text("issuer_url"),
@@ -31,21 +30,6 @@ export const settings = sqliteTable("settings", {
value: text("value").notNull(),
});
export const ssoProviders = sqliteTable("sso_providers", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
type: text("type").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
displayOrder: integer("display_order").notNull().default(0),
config: text("config").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const sessions = sqliteTable("sessions", {
id: text("id").primaryKey(),
userId: text("user_id")
@@ -54,9 +38,6 @@ export const sessions = sqliteTable("sessions", {
jwtToken: text("jwt_token").notNull(),
deviceType: text("device_type").notNull(),
deviceInfo: text("device_info").notNull(),
oidcSub: text("oidc_sub"),
oidcSid: text("oidc_sid"),
ssoProviderId: integer("sso_provider_id"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -83,25 +64,6 @@ 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")
@@ -116,7 +78,6 @@ export const hosts = sqliteTable("ssh_data", {
tags: text("tags"),
pin: integer("pin", { mode: "boolean" }).notNull().default(false),
authType: text("auth_type").notNull(),
useWarpgate: integer("use_warpgate", { mode: "boolean" }).notNull().default(false),
forceKeyboardInteractive: text("force_keyboard_interactive"),
password: text("password"),
@@ -133,22 +94,9 @@ 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),
enableSessionLogging: integer("enable_session_logging", { mode: "boolean" })
.notNull()
.default(true),
enableCommandHistory: integer("enable_command_history", { mode: "boolean" })
.notNull()
.default(true),
enableTunnel: integer("enable_tunnel", { mode: "boolean" })
.notNull()
.default(true),
@@ -157,13 +105,9 @@ export const hosts = sqliteTable("ssh_data", {
enableFileManager: integer("enable_file_manager", { mode: "boolean" })
.notNull()
.default(true),
scpLegacy: integer("scp_legacy", { mode: "boolean" }).notNull().default(false),
enableDocker: integer("enable_docker", { mode: "boolean" })
.notNull()
.default(false),
enableTmuxMonitor: integer("enable_tmux_monitor", { mode: "boolean" })
.notNull()
.default(false),
showTerminalInSidebar: integer("show_terminal_in_sidebar", { mode: "boolean" })
.notNull()
.default(true),
@@ -182,10 +126,6 @@ export const hosts = sqliteTable("ssh_data", {
defaultPath: text("default_path"),
statsConfig: text("stats_config"),
dockerConfig: text("docker_config"),
enableProxmox: integer("enable_proxmox", { mode: "boolean" })
.notNull()
.default(false),
proxmoxConfig: text("proxmox_config"),
terminalConfig: text("terminal_config"),
quickActions: text("quick_actions"),
notes: text("notes"),
@@ -199,24 +139,17 @@ export const hosts = sqliteTable("ssh_data", {
vncPort: integer("vnc_port").default(5900),
telnetPort: integer("telnet_port").default(23),
rdpCredentialId: integer("rdp_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
rdpUser: text("rdp_user"),
rdpPassword: text("rdp_password"),
rdpDomain: text("rdp_domain"),
rdpSecurity: text("rdp_security"),
rdpIgnoreCert: integer("rdp_ignore_cert", { mode: "boolean" }).default(false),
vncCredentialId: integer("vnc_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
vncPassword: text("vnc_password"),
vncUser: text("vnc_user"),
telnetUser: text("telnet_user"),
telnetPassword: text("telnet_password"),
telnetCredentialId: integer("telnet_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
rdpAuthType: text("rdp_auth_type"),
vncAuthType: text("vnc_auth_type"),
telnetAuthType: text("telnet_auth_type"),
domain: text("domain"),
security: text("security"),
@@ -231,7 +164,6 @@ export const hosts = sqliteTable("ssh_data", {
socks5ProxyChain: text("socks5_proxy_chain"),
macAddress: text("mac_address"),
wolBroadcastAddress: text("wol_broadcast_address"),
portKnockSequence: text("port_knock_sequence"),
hostKeyFingerprint: text("host_key_fingerprint"),
@@ -294,24 +226,6 @@ export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const transferRecent = sqliteTable("transfer_recent", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
sourceHostId: integer("source_host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
destHostId: integer("dest_host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
destPath: text("dest_path").notNull(),
destPathLabel: text("dest_path_label").notNull(),
lastUsed: text("last_used")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const dismissedAlerts = sqliteTable("dismissed_alerts", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
@@ -509,6 +423,21 @@ export const networkTopology = sqliteTable("network_topology", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const dashboardPreferences = sqliteTable("dashboard_preferences", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.unique()
.references(() => users.id, { onDelete: "cascade" }),
layout: text("layout").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const hostAccess = sqliteTable("host_access", {
id: integer("id").primaryKey({ autoIncrement: true }),
hostId: integer("host_id")
@@ -660,8 +589,6 @@ export const sessionRecordings = sqliteTable("session_recordings", {
dangerousActions: text("dangerous_actions"),
recordingPath: text("recording_path"),
protocol: text("protocol").notNull().default("ssh"),
format: text("format").notNull().default("text"),
terminatedByOwner: integer("terminated_by_owner", { mode: "boolean" })
.default(false),
@@ -692,63 +619,6 @@ 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")
@@ -762,324 +632,3 @@ export const apiKeys = sqliteTable("api_keys", {
lastUsedAt: text("last_used_at"),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
});
export const userOpenTabs = sqliteTable("user_open_tabs", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
tabType: text("tab_type").notNull(),
hostId: integer("host_id").references(() => hosts.id, { onDelete: "cascade" }),
label: text("label").notNull(),
tabOrder: integer("tab_order").notNull().default(0),
backendSessionId: text("backend_session_id"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const userPreferences = sqliteTable("user_preferences", {
userId: text("user_id")
.primaryKey()
.references(() => users.id, { onDelete: "cascade" }),
reopenTabsOnLogin: integer("reopen_tabs_on_login", { mode: "boolean" })
.notNull()
.default(false),
theme: text("theme"),
fontSize: text("font_size"),
accentColor: text("accent_color"),
language: text("language"),
storageMode: text("storage_mode"),
commandAutocomplete: integer("command_autocomplete", { mode: "boolean" }),
commandPaletteEnabled: integer("command_palette_enabled", { mode: "boolean" }),
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" }),
confirmTabClose: integer("confirm_tab_close", { mode: "boolean" }),
hiddenRailTabs: text("hidden_rail_tabs"),
compactHostView: integer("compact_host_view", { mode: "boolean" }),
statusColorScheme: text("status_color_scheme"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const hostMetricsPreferences = sqliteTable("host_metrics_preferences", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// JSON-encoded HostMetricsLayout. Layout has no secrets, so it is stored as
// plain JSON (no field-level encryption).
layout: text("layout").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const hostHealthChecks = sqliteTable("host_health_checks", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// JSON array of { id, name, type: "tcp"|"http", target, port, path }
checks: text("checks").notNull(),
intervalSeconds: integer("interval_seconds").notNull().default(300),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const hostHealthHistory = sqliteTable("host_health_history", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
checkId: text("check_id").notNull(),
ts: text("ts").notNull().default(sql`CURRENT_TIMESTAMP`),
ok: integer("ok", { mode: "boolean" }).notNull(),
latencyMs: integer("latency_ms"),
detail: text("detail"),
});
export const dashboardServiceLinks = sqliteTable("dashboard_service_links", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
label: text("label").notNull(),
url: text("url").notNull(),
order: integer("order").notNull().default(0),
createdAt: text("created_at")
.notNull()
.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 <host>/termix-id/u/<handle> >> ~/.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 /<ALGO> 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 }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
sessionName: text("session_name").notNull(),
tag: text("tag").notNull(),
createdAt: text("created_at")
.notNull()
.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 ---
@@ -1,383 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
import { AlertRepository } from "./alert-repository.js";
describe("AlertRepository", () => {
let adapter: SqliteDatabaseAdapter | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<AlertRepository> {
adapter = new SqliteDatabaseAdapter({
dialect: "sqlite",
url: ":memory:",
sqlitePath: ":memory:",
});
const context = await adapter.connect();
context.sqlite?.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL
);
CREATE TABLE ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT,
ip TEXT NOT NULL
);
CREATE TABLE alert_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER,
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
);
CREATE TABLE notification_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
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
);
CREATE TABLE alert_rule_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_id INTEGER NOT NULL,
channel_id INTEGER NOT NULL
);
CREATE TABLE alert_firings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
rule_id INTEGER NOT NULL,
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
);
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip)
VALUES (1, 'user-1', 'alpha', '127.0.0.1');
`);
return new AlertRepository(context, onWrite);
}
it("manages notification channels", async () => {
let writes = 0;
const repo = await createRepository(() => {
writes += 1;
});
const created = await repo.createNotificationChannel({
userId: "user-1",
name: "Ops",
type: "webhook",
config: '{"url":"https://example.test"}',
enabled: true,
});
expect(created).toMatchObject({
user_id: "user-1",
name: "Ops",
type: "webhook",
enabled: 1,
});
const updated = await repo.updateNotificationChannel(created.id, "user-1", {
name: "Ops disabled",
enabled: false,
});
expect(updated).toMatchObject({ name: "Ops disabled", enabled: 0 });
expect(await repo.listNotificationChannels("user-1")).toHaveLength(1);
expect(await repo.deleteNotificationChannel(created.id, "user-2")).toBe(
false,
);
expect(await repo.deleteNotificationChannel(created.id, "user-1")).toBe(
true,
);
expect(await repo.listNotificationChannels("user-1")).toHaveLength(0);
expect(writes).toBe(3);
});
it("manages alert rules and linked channels", async () => {
const repo = await createRepository();
const ownedChannel = await repo.createNotificationChannel({
userId: "user-1",
name: "Owned",
type: "ntfy",
config: '{"url":"https://ntfy.test","topic":"termix"}',
enabled: true,
});
const foreignChannel = await repo.createNotificationChannel({
userId: "user-2",
name: "Foreign",
type: "webhook",
config: '{"url":"https://example.test"}',
enabled: true,
});
const created = await repo.createAlertRule({
userId: "user-1",
hostId: null,
name: "CPU high",
enabled: true,
triggerType: "cpu_threshold",
thresholdValue: 80,
thresholdDurationSeconds: 30,
cooldownMinutes: 5,
channels: [ownedChannel.id, foreignChannel.id],
now: "2026-01-01T00:00:00.000Z",
});
expect(created).toMatchObject({
user_id: "user-1",
host_id: null,
name: "CPU high",
trigger_type: "cpu_threshold",
threshold_value: 80,
channels: [ownedChannel.id],
});
const updated = await repo.updateAlertRule(created.id, "user-1", {
name: "CPU very high",
hostId: 1,
channels: [],
now: "2026-01-02T00:00:00.000Z",
});
expect(updated).toMatchObject({
name: "CPU very high",
host_id: 1,
channels: [],
updated_at: "2026-01-02T00:00:00.000Z",
});
const rules = await repo.listAlertRules("user-1");
expect(rules).toHaveLength(1);
expect(rules[0].channels).toEqual([]);
expect(await repo.deleteAlertRule(created.id, "user-2")).toBe(false);
expect(await repo.deleteAlertRule(created.id, "user-1")).toBe(true);
});
it("lists, acknowledges, and prunes firings", async () => {
const repo = await createRepository();
const rule = await repo.createAlertRule({
userId: "user-1",
hostId: 1,
name: "Host offline",
enabled: true,
triggerType: "host_offline",
thresholdValue: null,
thresholdDurationSeconds: null,
cooldownMinutes: 15,
channels: [],
now: "2026-01-01T00:00:00.000Z",
});
await repo.createFiring({
userId: "user-1",
ruleId: rule.id,
hostId: 1,
hostName: "alpha",
value: null,
message: "down",
severity: "critical",
});
const listed = await repo.listAlertFirings({
userId: "user-1",
limit: 10,
offset: 0,
});
expect(listed.total).toBe(1);
expect(listed.firings[0]).toMatchObject({
rule_id: rule.id,
host_name: "alpha",
acknowledged: 0,
rule_name: "Host offline",
});
await repo.acknowledgeFiring(listed.firings[0].id, "user-1");
const unacknowledged = await repo.listAlertFirings({
userId: "user-1",
acknowledged: false,
limit: 10,
offset: 0,
});
expect(unacknowledged.total).toBe(0);
await repo.acknowledgeAllFirings("user-1");
repo.pruneFiringsOlderThan("user-1", 0);
});
it("loads enabled rules and notification channels for the alert engine", async () => {
const repo = await createRepository();
const channel = await repo.createNotificationChannel({
userId: "user-1",
name: "Ops",
type: "webhook",
config: '{"url":"https://example.test"}',
enabled: true,
});
const disabledChannel = await repo.createNotificationChannel({
userId: "user-1",
name: "Disabled",
type: "webhook",
config: '{"url":"https://disabled.test"}',
enabled: false,
});
const rule = await repo.createAlertRule({
userId: "user-1",
hostId: null,
name: "CPU high",
enabled: true,
triggerType: "cpu_threshold",
thresholdValue: 90,
thresholdDurationSeconds: 0,
cooldownMinutes: 15,
channels: [channel.id, disabledChannel.id],
now: "2026-01-01T00:00:00.000Z",
});
expect(await repo.listEnabledRulesForHost(1)).toMatchObject([
{
id: rule.id,
userId: "user-1",
triggerType: "cpu_threshold",
enabled: true,
},
]);
expect(await repo.findRuleById(rule.id)).toMatchObject({
id: rule.id,
cooldownMinutes: 15,
});
expect(await repo.listEnabledChannelsForRule(rule.id)).toEqual([
{
id: channel.id,
type: "webhook",
config: '{"url":"https://example.test"}',
enabled: true,
},
]);
});
it("loads host display names for alert payloads", async () => {
const repo = await createRepository();
expect(await repo.getHostDisplayName(1)).toBe("alpha");
expect(await repo.getHostDisplayName(999)).toBeNull();
});
it("deletes all alert data for a user", async () => {
let writes = 0;
const repo = await createRepository(() => {
writes += 1;
});
const userChannel = await repo.createNotificationChannel({
userId: "user-1",
name: "Ops",
type: "webhook",
config: '{"url":"https://example.test"}',
enabled: true,
});
const otherChannel = await repo.createNotificationChannel({
userId: "user-2",
name: "Other",
type: "webhook",
config: '{"url":"https://other.test"}',
enabled: true,
});
const userRule = await repo.createAlertRule({
userId: "user-1",
hostId: 1,
name: "CPU high",
enabled: true,
triggerType: "cpu_threshold",
thresholdValue: 80,
thresholdDurationSeconds: 30,
cooldownMinutes: 5,
channels: [userChannel.id],
now: "2026-01-01T00:00:00.000Z",
});
const otherRule = await repo.createAlertRule({
userId: "user-2",
hostId: null,
name: "Memory high",
enabled: true,
triggerType: "memory_threshold",
thresholdValue: 90,
thresholdDurationSeconds: 60,
cooldownMinutes: 10,
channels: [otherChannel.id],
now: "2026-01-01T00:00:00.000Z",
});
await repo.createFiring({
userId: "user-1",
ruleId: userRule.id,
hostId: 1,
hostName: "alpha",
value: 95,
message: "high",
severity: "warning",
});
await repo.createFiring({
userId: "user-2",
ruleId: otherRule.id,
hostId: 1,
hostName: "alpha",
value: 91,
message: "other",
severity: "warning",
});
await expect(repo.deleteByUserId("user-1")).resolves.toEqual({
firingsDeleted: 1,
ruleLinksDeleted: 1,
rulesDeleted: 1,
channelsDeleted: 1,
});
await expect(repo.deleteByUserId("missing")).resolves.toEqual({
firingsDeleted: 0,
ruleLinksDeleted: 0,
rulesDeleted: 0,
channelsDeleted: 0,
});
expect(await repo.listNotificationChannels("user-1")).toEqual([]);
expect(await repo.listAlertRules("user-1")).toEqual([]);
expect(
await repo.listAlertFirings({ userId: "user-1", limit: 10, offset: 0 }),
).toEqual({ firings: [], total: 0 });
expect(await repo.listNotificationChannels("user-2")).toHaveLength(1);
expect(await repo.listAlertRules("user-2")).toHaveLength(1);
expect(await repo.listEnabledChannelsForRule(otherRule.id)).toHaveLength(1);
expect(writes).toBe(7);
});
});
@@ -1,622 +0,0 @@
import { and, count, desc, eq, inArray, isNull, or } from "drizzle-orm";
import {
alertFirings,
alertRuleChannels,
alertRules,
hosts,
notificationChannels,
} from "../db/schema.js";
import type { DatabaseContext } from "../runtime/adapter.js";
type AlertRuleRecord = typeof alertRules.$inferSelect;
type NotificationChannelRecord = typeof notificationChannels.$inferSelect;
type AlertFiringRecord = typeof alertFirings.$inferSelect;
export interface NotificationChannelRow {
id: number;
user_id: string;
name: string;
type: string;
config: string;
enabled: number;
created_at: string;
}
export interface AlertRuleRow {
id: number;
user_id: string;
host_id: number | null;
name: string;
enabled: number;
trigger_type: string;
threshold_value: number | null;
threshold_duration_seconds: number | null;
cooldown_minutes: number;
created_at: string;
updated_at: string;
}
export interface AlertRuleWithChannelsRow extends AlertRuleRow {
channels: number[];
}
export interface AlertFiringRow {
id: number;
user_id: string;
rule_id: number;
host_id: number;
host_name: string;
fired_at: string;
resolved_at: string | null;
value: number | null;
message: string;
severity: string;
acknowledged: number;
rule_name: string | null;
}
export interface AlertEngineRule {
id: number;
userId: string;
hostId: number | null;
name: string;
enabled: boolean;
triggerType: string;
thresholdValue: number | null;
thresholdDurationSeconds: number | null;
cooldownMinutes: number;
}
export interface AlertEngineChannel {
id: number;
type: string;
config: string;
enabled: boolean;
}
export class AlertRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listNotificationChannels(
userId: string,
): Promise<NotificationChannelRow[]> {
const rows = await this.context.drizzle
.select()
.from(notificationChannels)
.where(eq(notificationChannels.userId, userId))
.orderBy(notificationChannels.id);
return rows.map(mapChannelRow);
}
async findNotificationChannelForUser(
id: number,
userId: string,
): Promise<NotificationChannelRow | null> {
const rows = await this.context.drizzle
.select()
.from(notificationChannels)
.where(
and(
eq(notificationChannels.id, id),
eq(notificationChannels.userId, userId),
),
)
.limit(1);
return rows[0] ? mapChannelRow(rows[0]) : null;
}
async createNotificationChannel(input: {
userId: string;
name: string;
type: string;
config: string;
enabled: boolean;
}): Promise<NotificationChannelRow> {
const [created] = await this.context.drizzle
.insert(notificationChannels)
.values({
userId: input.userId,
name: input.name,
type: input.type,
config: input.config,
enabled: input.enabled,
})
.returning();
await this.afterWrite();
return mapChannelRow(created);
}
async updateNotificationChannel(
id: number,
userId: string,
input: {
name?: string;
type?: string;
config?: string;
enabled?: boolean;
},
): Promise<NotificationChannelRow | null> {
if (Object.keys(input).length === 0) {
return this.findNotificationChannelForUser(id, userId);
}
const [updated] = await this.context.drizzle
.update(notificationChannels)
.set(input)
.where(
and(
eq(notificationChannels.id, id),
eq(notificationChannels.userId, userId),
),
)
.returning();
if (!updated) return null;
await this.afterWrite();
return mapChannelRow(updated);
}
async deleteNotificationChannel(
id: number,
userId: string,
): Promise<boolean> {
const deleted = await this.context.drizzle
.delete(notificationChannels)
.where(
and(
eq(notificationChannels.id, id),
eq(notificationChannels.userId, userId),
),
)
.returning({ id: notificationChannels.id });
if (deleted.length === 0) return false;
await this.afterWrite();
return true;
}
async listAlertRules(userId: string): Promise<AlertRuleWithChannelsRow[]> {
const rules = await this.context.drizzle
.select()
.from(alertRules)
.where(eq(alertRules.userId, userId))
.orderBy(alertRules.id);
const result: AlertRuleWithChannelsRow[] = [];
for (const rule of rules) {
result.push({
...mapRuleRow(rule),
channels: await this.listChannelIdsForRule(rule.id),
});
}
return result;
}
async createAlertRule(input: {
userId: string;
hostId: number | null;
name: string;
enabled: boolean;
triggerType: string;
thresholdValue: number | null;
thresholdDurationSeconds: number | null;
cooldownMinutes: number;
channels: number[];
now: string;
}): Promise<AlertRuleWithChannelsRow> {
const [created] = await this.context.drizzle
.insert(alertRules)
.values({
userId: input.userId,
hostId: input.hostId,
name: input.name,
enabled: input.enabled,
triggerType: input.triggerType,
thresholdValue: input.thresholdValue,
thresholdDurationSeconds: input.thresholdDurationSeconds,
cooldownMinutes: input.cooldownMinutes,
createdAt: input.now,
updatedAt: input.now,
})
.returning();
const channels = await this.replaceRuleChannels(
created.id,
input.userId,
input.channels,
);
await this.afterWrite();
return { ...mapRuleRow(created), channels };
}
async findAlertRuleForUser(
id: number,
userId: string,
): Promise<AlertRuleRow | null> {
const rows = await this.context.drizzle
.select()
.from(alertRules)
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)))
.limit(1);
return rows[0] ? mapRuleRow(rows[0]) : null;
}
async updateAlertRule(
id: number,
userId: string,
input: {
name?: string;
hostId?: number | null;
enabled?: boolean;
triggerType?: string;
thresholdValue?: number | null;
thresholdDurationSeconds?: number | null;
cooldownMinutes?: number;
channels?: number[];
now: string;
},
): Promise<AlertRuleWithChannelsRow | null> {
const [updated] = await this.context.drizzle
.update(alertRules)
.set({
...(input.name !== undefined ? { name: input.name } : {}),
...(input.hostId !== undefined ? { hostId: input.hostId } : {}),
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
...(input.triggerType !== undefined
? { triggerType: input.triggerType }
: {}),
...(input.thresholdValue !== undefined
? { thresholdValue: input.thresholdValue }
: {}),
...(input.thresholdDurationSeconds !== undefined
? { thresholdDurationSeconds: input.thresholdDurationSeconds }
: {}),
...(input.cooldownMinutes !== undefined
? { cooldownMinutes: input.cooldownMinutes }
: {}),
updatedAt: input.now,
})
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)))
.returning();
if (!updated) return null;
const channels =
input.channels === undefined
? await this.listChannelIdsForRule(id)
: await this.replaceRuleChannels(id, userId, input.channels);
await this.afterWrite();
return { ...mapRuleRow(updated), channels };
}
async deleteAlertRule(id: number, userId: string): Promise<boolean> {
const deleted = await this.context.drizzle
.delete(alertRules)
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)))
.returning({ id: alertRules.id });
if (deleted.length === 0) return false;
await this.afterWrite();
return true;
}
async listAlertFirings(input: {
userId: string;
acknowledged?: boolean;
limit: number;
offset: number;
}): Promise<{ firings: AlertFiringRow[]; total: number }> {
const filters = [eq(alertFirings.userId, input.userId)];
if (input.acknowledged !== undefined) {
filters.push(eq(alertFirings.acknowledged, input.acknowledged));
}
const where = and(...filters);
const rows = await this.context.drizzle
.select({
firing: alertFirings,
ruleName: alertRules.name,
})
.from(alertFirings)
.leftJoin(alertRules, eq(alertRules.id, alertFirings.ruleId))
.where(where)
.orderBy(desc(alertFirings.firedAt))
.limit(input.limit)
.offset(input.offset);
const totalRows = await this.context.drizzle
.select({ total: count() })
.from(alertFirings)
.where(where);
return {
firings: rows.map((row) => mapFiringRow(row.firing, row.ruleName)),
total: totalRows[0]?.total ?? 0,
};
}
async acknowledgeFiring(id: number, userId: string): Promise<void> {
await this.context.drizzle
.update(alertFirings)
.set({ acknowledged: true })
.where(and(eq(alertFirings.id, id), eq(alertFirings.userId, userId)));
await this.afterWrite();
}
async acknowledgeAllFirings(userId: string): Promise<void> {
await this.context.drizzle
.update(alertFirings)
.set({ acknowledged: true })
.where(eq(alertFirings.userId, userId));
await this.afterWrite();
}
async listEnabledRulesForHost(hostId: number): Promise<AlertEngineRule[]> {
const rows = await this.context.drizzle
.select()
.from(alertRules)
.where(
and(
eq(alertRules.enabled, true),
or(eq(alertRules.hostId, hostId), isNull(alertRules.hostId)),
),
);
return rows.map(mapEngineRule);
}
async listEnabledRulesForHostUser(
hostId: number,
userId: string,
): Promise<AlertEngineRule[]> {
const rows = await this.context.drizzle
.select()
.from(alertRules)
.where(
and(
eq(alertRules.enabled, true),
eq(alertRules.userId, userId),
or(eq(alertRules.hostId, hostId), isNull(alertRules.hostId)),
),
);
return rows.map(mapEngineRule);
}
async findRuleById(id: number): Promise<AlertEngineRule | null> {
const rows = await this.context.drizzle
.select()
.from(alertRules)
.where(eq(alertRules.id, id))
.limit(1);
return rows[0] ? mapEngineRule(rows[0]) : null;
}
async createFiring(input: {
userId: string;
ruleId: number;
hostId: number;
hostName: string;
value: number | null;
message: string;
severity: string;
}): Promise<void> {
await this.context.drizzle.insert(alertFirings).values(input);
await this.afterWrite();
}
pruneFiringsOlderThan(userId: string, days: number): void {
this.context.sqlite
?.prepare(
"DELETE FROM alert_firings WHERE user_id = ? AND fired_at < datetime('now', ?)",
)
.run(userId, `-${days} days`);
}
async deleteByUserId(userId: string): Promise<{
firingsDeleted: number;
ruleLinksDeleted: number;
rulesDeleted: number;
channelsDeleted: number;
}> {
const ruleIds = (
await this.context.drizzle
.select({ id: alertRules.id })
.from(alertRules)
.where(eq(alertRules.userId, userId))
).map((row) => row.id);
const channelIds = (
await this.context.drizzle
.select({ id: notificationChannels.id })
.from(notificationChannels)
.where(eq(notificationChannels.userId, userId))
).map((row) => row.id);
const firingRows = await this.context.drizzle
.delete(alertFirings)
.where(eq(alertFirings.userId, userId))
.returning({ id: alertFirings.id });
const linkFilters = [
...(ruleIds.length > 0
? [inArray(alertRuleChannels.ruleId, ruleIds)]
: []),
...(channelIds.length > 0
? [inArray(alertRuleChannels.channelId, channelIds)]
: []),
];
const linkRows =
linkFilters.length === 0
? []
: await this.context.drizzle
.delete(alertRuleChannels)
.where(or(...linkFilters))
.returning({ id: alertRuleChannels.id });
const ruleRows = await this.context.drizzle
.delete(alertRules)
.where(eq(alertRules.userId, userId))
.returning({ id: alertRules.id });
const channelRows = await this.context.drizzle
.delete(notificationChannels)
.where(eq(notificationChannels.userId, userId))
.returning({ id: notificationChannels.id });
if (
firingRows.length > 0 ||
linkRows.length > 0 ||
ruleRows.length > 0 ||
channelRows.length > 0
) {
await this.afterWrite();
}
return {
firingsDeleted: firingRows.length,
ruleLinksDeleted: linkRows.length,
rulesDeleted: ruleRows.length,
channelsDeleted: channelRows.length,
};
}
async listEnabledChannelsForRule(
ruleId: number,
): Promise<AlertEngineChannel[]> {
const rows = await this.context.drizzle
.select({
id: notificationChannels.id,
type: notificationChannels.type,
config: notificationChannels.config,
enabled: notificationChannels.enabled,
})
.from(notificationChannels)
.innerJoin(
alertRuleChannels,
eq(alertRuleChannels.channelId, notificationChannels.id),
)
.where(
and(
eq(alertRuleChannels.ruleId, ruleId),
eq(notificationChannels.enabled, true),
),
);
return rows;
}
async getHostDisplayName(hostId: number): Promise<string | null> {
const rows = await this.context.drizzle
.select({ name: hosts.name, ip: hosts.ip })
.from(hosts)
.where(eq(hosts.id, hostId))
.limit(1);
const row = rows[0];
return row ? row.name || row.ip : null;
}
private async replaceRuleChannels(
ruleId: number,
userId: string,
channelIds: number[],
): Promise<number[]> {
await this.context.drizzle
.delete(alertRuleChannels)
.where(eq(alertRuleChannels.ruleId, ruleId));
const linked: number[] = [];
for (const channelId of channelIds) {
const channel = await this.findNotificationChannelForUser(
channelId,
userId,
);
if (!channel) continue;
await this.context.drizzle
.insert(alertRuleChannels)
.values({ ruleId, channelId });
linked.push(channelId);
}
return linked;
}
private async listChannelIdsForRule(ruleId: number): Promise<number[]> {
const rows = await this.context.drizzle
.select({ channelId: alertRuleChannels.channelId })
.from(alertRuleChannels)
.where(eq(alertRuleChannels.ruleId, ruleId));
return rows.map((row) => row.channelId);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
function mapChannelRow(row: NotificationChannelRecord): NotificationChannelRow {
return {
id: row.id,
user_id: row.userId,
name: row.name,
type: row.type,
config: row.config,
enabled: row.enabled ? 1 : 0,
created_at: row.createdAt,
};
}
function mapRuleRow(row: AlertRuleRecord): AlertRuleRow {
return {
id: row.id,
user_id: row.userId,
host_id: row.hostId,
name: row.name,
enabled: row.enabled ? 1 : 0,
trigger_type: row.triggerType,
threshold_value: row.thresholdValue,
threshold_duration_seconds: row.thresholdDurationSeconds,
cooldown_minutes: row.cooldownMinutes,
created_at: row.createdAt,
updated_at: row.updatedAt,
};
}
function mapFiringRow(
row: AlertFiringRecord,
ruleName: string | null,
): AlertFiringRow {
return {
id: row.id,
user_id: row.userId,
rule_id: row.ruleId,
host_id: row.hostId,
host_name: row.hostName,
fired_at: row.firedAt,
resolved_at: row.resolvedAt,
value: row.value,
message: row.message,
severity: row.severity,
acknowledged: row.acknowledged ? 1 : 0,
rule_name: ruleName,
};
}
function mapEngineRule(row: AlertRuleRecord): AlertEngineRule {
return {
id: row.id,
userId: row.userId,
hostId: row.hostId,
name: row.name,
enabled: row.enabled,
triggerType: row.triggerType,
thresholdValue: row.thresholdValue,
thresholdDurationSeconds: row.thresholdDurationSeconds,
cooldownMinutes: row.cooldownMinutes,
};
}
@@ -1,149 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
import { ApiKeyRepository } from "./api-key-repository.js";
describe("ApiKeyRepository", () => {
let adapter: SqliteDatabaseAdapter | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(onWrite?: () => void): Promise<{
apiKeys: ApiKeyRepository;
}> {
adapter = new SqliteDatabaseAdapter({
dialect: "sqlite",
url: ":memory:",
sqlitePath: ":memory:",
});
const context = await adapter.connect();
context.sqlite?.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
is_oidc INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
token_hash TEXT NOT NULL,
token_prefix TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TEXT,
last_used_at TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
INSERT INTO users (id, username, password_hash) VALUES
('user-1', 'admin', 'hash'),
('user-2', 'target', 'hash');
`);
return {
apiKeys: new ApiKeyRepository(context, onWrite),
};
}
it("creates, lists, finds, updates last used time, and deletes keys", async () => {
const repo = await createRepository();
await repo.apiKeys.create({
id: "key-1",
userId: "user-2",
name: "deploy",
tokenHash: "hash",
tokenPrefix: "tmx_12345678",
createdAt: "2026-06-26T00:00:00.000Z",
expiresAt: null,
lastUsedAt: null,
isActive: true,
});
expect((await repo.apiKeys.findById("key-1"))?.name).toBe("deploy");
expect(
(await repo.apiKeys.listActiveByTokenPrefix("tmx_12345678")).map(
(key) => key.id,
),
).toEqual(["key-1"]);
expect(await repo.apiKeys.listAllWithUsers()).toMatchObject([
{
id: "key-1",
userId: "user-2",
username: "target",
tokenPrefix: "tmx_12345678",
},
]);
await repo.apiKeys.updateLastUsedAt("key-1", "2026-06-26T01:00:00.000Z");
expect((await repo.apiKeys.findById("key-1"))?.lastUsedAt).toBe(
"2026-06-26T01:00:00.000Z",
);
expect((await repo.apiKeys.delete("key-1"))?.name).toBe("deploy");
expect(await repo.apiKeys.findById("key-1")).toBeNull();
});
it("runs the write hook after key writes", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.apiKeys.create({
id: "key-1",
userId: "user-1",
name: "ops",
tokenHash: "hash",
tokenPrefix: "tmx_87654321",
isActive: true,
});
await repo.apiKeys.updateLastUsedAt("key-1", "2026-06-26T01:00:00.000Z");
await repo.apiKeys.delete("key-1");
expect(writeCount).toBe(3);
});
it("deletes all API keys for a user", async () => {
const repo = await createRepository();
await repo.apiKeys.create({
id: "key-1",
userId: "user-2",
name: "deploy",
tokenHash: "hash-1",
tokenPrefix: "tmx_11111111",
isActive: true,
});
await repo.apiKeys.create({
id: "key-2",
userId: "user-2",
name: "ops",
tokenHash: "hash-2",
tokenPrefix: "tmx_22222222",
isActive: true,
});
await repo.apiKeys.create({
id: "key-3",
userId: "user-1",
name: "admin",
tokenHash: "hash-3",
tokenPrefix: "tmx_33333333",
isActive: true,
});
await expect(repo.apiKeys.deleteByUserId("user-2")).resolves.toBe(2);
expect(await repo.apiKeys.findById("key-1")).toBeNull();
expect(await repo.apiKeys.findById("key-2")).toBeNull();
expect((await repo.apiKeys.findById("key-3"))?.userId).toBe("user-1");
});
});
@@ -1,103 +0,0 @@
import { eq, and } from "drizzle-orm";
import { apiKeys, users } from "../db/schema.js";
import type { DatabaseContext } from "../runtime/adapter.js";
export type ApiKeyRecord = typeof apiKeys.$inferSelect;
export type NewApiKeyRecord = typeof apiKeys.$inferInsert;
export interface ApiKeyListRecord {
id: string;
name: string;
userId: string;
username: string | null;
tokenPrefix: string;
createdAt: string;
expiresAt: string | null;
lastUsedAt: string | null;
isActive: boolean;
}
export class ApiKeyRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async create(apiKey: NewApiKeyRecord): Promise<ApiKeyRecord> {
const rows = await this.context.drizzle
.insert(apiKeys)
.values(apiKey)
.returning();
await this.afterWrite();
return rows[0];
}
async listAllWithUsers(): Promise<ApiKeyListRecord[]> {
return this.context.drizzle
.select({
id: apiKeys.id,
name: apiKeys.name,
userId: apiKeys.userId,
username: users.username,
tokenPrefix: apiKeys.tokenPrefix,
createdAt: apiKeys.createdAt,
expiresAt: apiKeys.expiresAt,
lastUsedAt: apiKeys.lastUsedAt,
isActive: apiKeys.isActive,
})
.from(apiKeys)
.leftJoin(users, eq(apiKeys.userId, users.id))
.orderBy(apiKeys.createdAt);
}
async findById(id: string): Promise<ApiKeyRecord | null> {
const rows = await this.context.drizzle
.select()
.from(apiKeys)
.where(eq(apiKeys.id, id))
.limit(1);
return rows[0] ?? null;
}
async listActiveByTokenPrefix(tokenPrefix: string): Promise<ApiKeyRecord[]> {
return this.context.drizzle
.select()
.from(apiKeys)
.where(
and(eq(apiKeys.tokenPrefix, tokenPrefix), eq(apiKeys.isActive, true)),
);
}
async updateLastUsedAt(id: string, lastUsedAt: string): Promise<void> {
await this.context.drizzle
.update(apiKeys)
.set({ lastUsedAt })
.where(eq(apiKeys.id, id));
await this.afterWrite();
}
async delete(id: string): Promise<ApiKeyRecord | null> {
const rows = await this.context.drizzle
.delete(apiKeys)
.where(eq(apiKeys.id, id))
.returning();
await this.afterWrite();
return rows[0] ?? null;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
.delete(apiKeys)
.where(eq(apiKeys.userId, userId))
.returning({ id: apiKeys.id });
await this.afterWrite();
return rows.length;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,136 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
import { AuditLogRepository } from "./audit-log-repository.js";
describe("AuditLogRepository", () => {
let adapter: SqliteDatabaseAdapter | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<AuditLogRepository> {
adapter = new SqliteDatabaseAdapter({
dialect: "sqlite",
url: ":memory:",
sqlitePath: ":memory:",
});
const context = await adapter.connect();
context.sqlite?.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
is_oidc INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
username TEXT NOT NULL,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT,
resource_name TEXT,
details TEXT,
ip_address TEXT,
user_agent TEXT,
success INTEGER NOT NULL,
error_message TEXT,
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new AuditLogRepository(context, onWrite);
}
it("creates, filters, pages, and lists actions", async () => {
const repo = await createRepository();
await repo.create({
userId: "user-1",
username: "alice",
action: "create_host",
resourceType: "host",
resourceId: "1",
success: true,
timestamp: "2026-06-27T00:00:00.000Z",
});
await repo.create({
userId: "user-2",
username: "bob",
action: "delete_host",
resourceType: "host",
resourceId: "2",
success: false,
timestamp: "2026-06-27T01:00:00.000Z",
});
const page = await repo.listPage({
filters: {
resourceType: "host",
success: false,
startDate: "2026-06-27T00:30:00.000Z",
},
limit: 10,
offset: 0,
});
expect(page.total).toBe(1);
expect(page.logs[0]).toMatchObject({
userId: "user-2",
action: "delete_host",
success: false,
});
expect(await repo.listDistinctActions()).toEqual([
"create_host",
"delete_host",
]);
});
it("deletes logs by user id and only runs write hook for deleted rows", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.create({
userId: "user-1",
username: "alice",
action: "login",
resourceType: "auth",
success: true,
});
await repo.create({
userId: "user-2",
username: "bob",
action: "login",
resourceType: "auth",
success: true,
});
expect(await repo.deleteByUserId("missing")).toBe(0);
expect(writeCount).toBe(2);
expect(await repo.deleteByUserId("user-1")).toBe(1);
expect(writeCount).toBe(3);
expect(
(
await repo.listPage({
filters: {},
limit: 10,
offset: 0,
})
).logs.map((log) => log.userId),
).toEqual(["user-2"]);
});
});
@@ -1,135 +0,0 @@
import { and, asc, desc, eq, gte, inArray, lte, sql } from "drizzle-orm";
import { auditLogs } from "../db/schema.js";
import type { DatabaseContext } from "../runtime/adapter.js";
export type AuditLogRecord = typeof auditLogs.$inferSelect;
export type NewAuditLogRecord = typeof auditLogs.$inferInsert;
export type AuditLogFilters = {
userId?: string;
action?: string;
resourceType?: string;
success?: boolean;
startDate?: string;
endDate?: string;
};
export type AuditLogPage = {
logs: AuditLogRecord[];
total: number;
};
const PRUNE_MAX = 10000;
const PRUNE_TARGET = 9000;
export class AuditLogRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async create(entry: NewAuditLogRecord): Promise<void> {
await this.context.drizzle.insert(auditLogs).values(entry);
await this.pruneIfNeeded();
await this.afterWrite();
}
async listPage(input: {
filters: AuditLogFilters;
limit: number;
offset: number;
}): Promise<AuditLogPage> {
const whereClause = this.buildWhere(input.filters);
const [logs, totalResult] = await Promise.all([
this.context.drizzle
.select()
.from(auditLogs)
.where(whereClause)
.orderBy(desc(auditLogs.timestamp))
.limit(input.limit)
.offset(input.offset),
this.context.drizzle
.select({ count: sql<number>`COUNT(*)` })
.from(auditLogs)
.where(whereClause),
]);
return {
logs,
total: totalResult[0]?.count ?? 0,
};
}
async listDistinctActions(): Promise<string[]> {
const rows = await this.context.drizzle
.selectDistinct({ action: auditLogs.action })
.from(auditLogs)
.orderBy(asc(auditLogs.action));
return rows.map((row) => row.action);
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
.delete(auditLogs)
.where(eq(auditLogs.userId, userId))
.returning({ id: auditLogs.id });
if (rows.length > 0) {
await this.afterWrite();
}
return rows.length;
}
private buildWhere(filters: AuditLogFilters) {
const conditions = [];
if (filters.userId) conditions.push(eq(auditLogs.userId, filters.userId));
if (filters.action) conditions.push(eq(auditLogs.action, filters.action));
if (filters.resourceType) {
conditions.push(eq(auditLogs.resourceType, filters.resourceType));
}
if (filters.success !== undefined) {
conditions.push(eq(auditLogs.success, filters.success));
}
if (filters.startDate) {
conditions.push(gte(auditLogs.timestamp, filters.startDate));
}
if (filters.endDate) {
conditions.push(lte(auditLogs.timestamp, filters.endDate));
}
return conditions.length > 0 ? and(...conditions) : undefined;
}
private async pruneIfNeeded(): Promise<void> {
const countResult = await this.context.drizzle
.select({ count: sql<number>`COUNT(*)` })
.from(auditLogs);
const count = countResult[0]?.count ?? 0;
if (count < PRUNE_MAX) {
return;
}
const deleteCount = count - PRUNE_TARGET;
const rows = await this.context.drizzle
.select({ id: auditLogs.id })
.from(auditLogs)
.orderBy(asc(auditLogs.timestamp))
.limit(deleteCount);
const ids = rows.map((row) => row.id);
if (ids.length > 0) {
await this.context.drizzle
.delete(auditLogs)
.where(inArray(auditLogs.id, ids));
}
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,126 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
import { C2sTunnelPresetRepository } from "./c2s-tunnel-preset-repository.js";
describe("C2sTunnelPresetRepository", () => {
let adapter: SqliteDatabaseAdapter | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<C2sTunnelPresetRepository> {
adapter = new SqliteDatabaseAdapter({
dialect: "sqlite",
url: ":memory:",
sqlitePath: ":memory:",
});
const context = await adapter.connect();
context.sqlite?.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL
);
CREATE TABLE c2s_tunnel_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
config TEXT NOT NULL,
platform TEXT,
computer_name TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new C2sTunnelPresetRepository(context, onWrite);
}
it("creates and lists presets ordered by name", async () => {
const repo = await createRepository();
await repo.createForUser("user-1", {
name: "Zulu",
config: "[]",
platform: "linux",
computerName: "workstation",
});
await repo.createForUser("user-1", { name: "Alpha", config: "[]" });
await repo.createForUser("user-2", { name: "Other", config: "[]" });
const presets = await repo.listByUserId("user-1");
expect(presets.map((preset) => preset.name)).toEqual(["Alpha", "Zulu"]);
expect(presets[1]).toMatchObject({
platform: "linux",
computerName: "workstation",
});
});
it("finds, updates, and deletes user-owned presets", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const preset = await repo.createForUser("user-1", {
name: "Home",
config: "[]",
});
expect(writeCount).toBe(1);
expect(await repo.findByIdForUser("user-2", preset.id)).toBeNull();
expect(await repo.hasNameForUser("user-1", "Home")).toBe(true);
expect(await repo.hasNameForUser("user-1", "Home", preset.id)).toBe(false);
const updated = await repo.updateForUser("user-1", preset.id, {
name: "Renamed",
platform: "darwin",
});
expect(updated).toMatchObject({
id: preset.id,
name: "Renamed",
platform: "darwin",
});
expect(writeCount).toBe(2);
expect(
await repo.updateForUser("user-2", preset.id, { name: "Nope" }),
).toBeNull();
expect(writeCount).toBe(2);
expect(await repo.deleteForUser("user-2", preset.id)).toBe(false);
expect(await repo.deleteForUser("user-1", preset.id)).toBe(true);
expect(writeCount).toBe(3);
});
it("deletes all presets for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.createForUser("user-1", { name: "One", config: "[]" });
await repo.createForUser("user-1", { name: "Two", config: "[]" });
await repo.createForUser("user-2", { name: "Other", config: "[]" });
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(await repo.listByUserId("user-1")).toEqual([]);
expect(
(await repo.listByUserId("user-2")).map((preset) => preset.name),
).toEqual(["Other"]);
expect(writeCount).toBe(4);
});
});
@@ -1,136 +0,0 @@
import { and, asc, eq, sql } from "drizzle-orm";
import { c2sTunnelPresets } from "../db/schema.js";
import type { DatabaseContext } from "../runtime/adapter.js";
export type C2sTunnelPresetRecord = typeof c2sTunnelPresets.$inferSelect;
export interface C2sTunnelPresetCreateInput {
name: string;
config: string;
platform?: string | null;
computerName?: string | null;
}
export type C2sTunnelPresetUpdateInput = Partial<C2sTunnelPresetCreateInput>;
export class C2sTunnelPresetRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listByUserId(userId: string): Promise<C2sTunnelPresetRecord[]> {
return this.context.drizzle
.select()
.from(c2sTunnelPresets)
.where(eq(c2sTunnelPresets.userId, userId))
.orderBy(asc(c2sTunnelPresets.name));
}
async findByIdForUser(
userId: string,
id: number,
): Promise<C2sTunnelPresetRecord | null> {
const rows = await this.context.drizzle
.select()
.from(c2sTunnelPresets)
.where(
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
)
.limit(1);
return rows[0] ?? null;
}
async hasNameForUser(
userId: string,
name: string,
excludingId?: number,
): Promise<boolean> {
const rows = await this.context.drizzle
.select({ id: c2sTunnelPresets.id })
.from(c2sTunnelPresets)
.where(
and(
eq(c2sTunnelPresets.userId, userId),
eq(c2sTunnelPresets.name, name),
),
);
return rows.some((row) => row.id !== excludingId);
}
async createForUser(
userId: string,
input: C2sTunnelPresetCreateInput,
): Promise<C2sTunnelPresetRecord> {
const [created] = await this.context.drizzle
.insert(c2sTunnelPresets)
.values({
userId,
name: input.name,
config: input.config,
platform: input.platform ?? null,
computerName: input.computerName ?? null,
})
.returning();
await this.afterWrite();
return created;
}
async updateForUser(
userId: string,
id: number,
updates: C2sTunnelPresetUpdateInput,
): Promise<C2sTunnelPresetRecord | null> {
const [updated] = await this.context.drizzle
.update(c2sTunnelPresets)
.set({
...updates,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
)
.returning();
if (updated) {
await this.afterWrite();
}
return updated ?? null;
}
async deleteForUser(userId: string, id: number): Promise<boolean> {
const rows = await this.context.drizzle
.delete(c2sTunnelPresets)
.where(
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
)
.returning({ id: c2sTunnelPresets.id });
if (rows.length > 0) {
await this.afterWrite();
}
return rows.length > 0;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
.delete(c2sTunnelPresets)
.where(eq(c2sTunnelPresets.userId, userId))
.returning({ id: c2sTunnelPresets.id });
if (rows.length > 0) {
await this.afterWrite();
}
return rows.length;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,102 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
import { CommandHistoryRepository } from "./command-history-repository.js";
describe("CommandHistoryRepository", () => {
let adapter: SqliteDatabaseAdapter | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<CommandHistoryRepository> {
adapter = new SqliteDatabaseAdapter({
dialect: "sqlite",
url: ":memory:",
sqlitePath: ":memory:",
});
const context = await adapter.connect();
context.sqlite?.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
is_oidc INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL
);
CREATE TABLE command_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
command TEXT NOT NULL,
executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO hosts (id, user_id, name)
VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other');
`);
return new CommandHistoryRepository(context, onWrite);
}
it("creates and lists unique commands by latest execution", async () => {
const repo = await createRepository();
await repo.create("user-1", 1, "ls", "2026-06-27T00:00:00.000Z");
await repo.create("user-1", 1, "pwd", "2026-06-27T01:00:00.000Z");
await repo.create("user-1", 1, "ls", "2026-06-27T02:00:00.000Z");
await repo.create("user-2", 3, "whoami", "2026-06-27T03:00:00.000Z");
expect(await repo.listUniqueCommandsForHost("user-1", 1)).toEqual([
"ls",
"pwd",
]);
expect(await repo.listCommandsForHost("user-1", 1)).toEqual([
"ls",
"pwd",
"ls",
]);
});
it("deletes commands by command, host, host list, and user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.create("user-1", 1, "ls");
await repo.create("user-1", 1, "ls");
await repo.create("user-1", 2, "pwd");
await repo.create("user-2", 3, "whoami");
expect(writeCount).toBe(4);
expect(await repo.deleteCommandForHost("user-1", 1, "missing")).toBe(0);
expect(writeCount).toBe(4);
expect(await repo.deleteCommandForHost("user-1", 1, "ls")).toBe(2);
expect(writeCount).toBe(5);
expect(await repo.deleteByUserAndHost("user-1", 2)).toBe(1);
expect(await repo.deleteByHostIds([])).toBe(0);
expect(await repo.deleteByHostIds([3])).toBe(1);
expect(writeCount).toBe(7);
await repo.create("user-1", 1, "date");
expect(await repo.deleteByUserId("user-1")).toBe(1);
expect(writeCount).toBe(9);
});
});
@@ -1,161 +0,0 @@
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { commandHistory } from "../db/schema.js";
import type { DatabaseContext } from "../runtime/adapter.js";
export type CommandHistoryRecord = typeof commandHistory.$inferSelect;
export class CommandHistoryRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async create(
userId: string,
hostId: number,
command: string,
executedAt = new Date().toISOString(),
): Promise<CommandHistoryRecord> {
const [created] = await this.context.drizzle
.insert(commandHistory)
.values({ userId, hostId, command, executedAt })
.returning();
await this.afterWrite();
return created;
}
async listUniqueCommandsForHost(
userId: string,
hostId: number,
limit = 500,
): Promise<string[]> {
const rows = await this.context.drizzle
.select({
command: commandHistory.command,
maxExecutedAt: sql<number>`MAX(${commandHistory.executedAt})`,
})
.from(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostId),
),
)
.groupBy(commandHistory.command)
.orderBy(desc(sql`MAX(${commandHistory.executedAt})`))
.limit(limit);
return rows.map((row) => row.command);
}
async listCommandsForHost(
userId: string,
hostId: number,
limit = 200,
): Promise<string[]> {
const rows = await this.context.drizzle
.select({
id: commandHistory.id,
command: commandHistory.command,
})
.from(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostId),
),
)
.orderBy(desc(commandHistory.executedAt))
.limit(limit);
return rows.map((row) => row.command);
}
async deleteCommandForHost(
userId: string,
hostId: number,
command: string,
): Promise<number> {
const rows = await this.context.drizzle
.delete(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostId),
eq(commandHistory.command, command),
),
)
.returning({ id: commandHistory.id });
if (rows.length > 0) {
await this.afterWrite();
}
return rows.length;
}
async deleteByUserAndHost(userId: string, hostId: number): Promise<number> {
const rows = await this.context.drizzle
.delete(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostId),
),
)
.returning({ id: commandHistory.id });
if (rows.length > 0) {
await this.afterWrite();
}
return rows.length;
}
async deleteByHostId(hostId: number): Promise<number> {
const rows = await this.context.drizzle
.delete(commandHistory)
.where(eq(commandHistory.hostId, hostId))
.returning({ id: commandHistory.id });
if (rows.length > 0) {
await this.afterWrite();
}
return rows.length;
}
async deleteByHostIds(hostIds: number[]): Promise<number> {
if (hostIds.length === 0) {
return 0;
}
const rows = await this.context.drizzle
.delete(commandHistory)
.where(inArray(commandHistory.hostId, hostIds))
.returning({ id: commandHistory.id });
if (rows.length > 0) {
await this.afterWrite();
}
return rows.length;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
.delete(commandHistory)
.where(eq(commandHistory.userId, userId))
.returning({ id: commandHistory.id });
if (rows.length > 0) {
await this.afterWrite();
}
return rows.length;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,360 +0,0 @@
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
import { sshCredentials, sshCredentialUsage } from "../db/schema.js";
import type { DatabaseContext } from "../runtime/adapter.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { SystemCrypto } from "../../utils/system-crypto.js";
export type CredentialRecord = typeof sshCredentials.$inferSelect;
export type NewCredentialRecord = typeof sshCredentials.$inferInsert;
export type CredentialUpdate = Partial<
Omit<NewCredentialRecord, "id" | "userId">
>;
export type CredentialSystemEncryptionUpdate = Pick<
CredentialUpdate,
"systemPassword" | "systemKey" | "systemKeyPassword" | "updatedAt"
>;
export class CredentialRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async create(credential: NewCredentialRecord): Promise<CredentialRecord> {
const rows = await this.context.drizzle
.insert(sshCredentials)
.values(credential)
.returning();
await this.afterWrite();
return rows[0];
}
async createEncryptedForUser(
userId: string,
credential: NewCredentialRecord | Record<string, unknown>,
): Promise<CredentialRecord> {
const userDataKey = DataCrypto.validateUserAccess(userId);
const tempId = credential.id ?? Date.now();
const dataWithTempId = { ...credential, id: tempId };
const encryptedCredential = await this.encryptCredentialRecordForWrite(
dataWithTempId,
userId,
userDataKey,
);
if (!credential.id) {
delete (encryptedCredential as Partial<NewCredentialRecord>).id;
}
const rows = await this.context.drizzle
.insert(sshCredentials)
.values(encryptedCredential as NewCredentialRecord)
.returning();
await this.afterWrite();
return DataCrypto.decryptRecord(
"ssh_credentials",
rows[0],
userId,
userDataKey,
);
}
async findByIdForUser(
userId: string,
credentialId: number,
): Promise<CredentialRecord | null> {
const rows = await this.context.drizzle
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
)
.limit(1);
return rows[0] ?? null;
}
async findById(credentialId: number): Promise<CredentialRecord | null> {
const rows = await this.context.drizzle
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, credentialId))
.limit(1);
return rows[0] ?? null;
}
async listByUserId(userId: string): Promise<CredentialRecord[]> {
return this.context.drizzle
.select()
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId))
.orderBy(desc(sshCredentials.updatedAt));
}
async listMissingSystemEncryptionByUserId(
userId: string,
): Promise<CredentialRecord[]> {
return this.context.drizzle
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.userId, userId),
or(
isNull(sshCredentials.systemPassword),
isNull(sshCredentials.systemKey),
isNull(sshCredentials.systemKeyPassword),
),
),
);
}
async existsForImportIdentity(
userId: string,
name: string,
username: string | null,
): Promise<boolean> {
const rows = await this.context.drizzle
.select({ id: sshCredentials.id })
.from(sshCredentials)
.where(
and(
eq(sshCredentials.userId, userId),
eq(sshCredentials.name, name),
eq(sshCredentials.username, username),
),
)
.limit(1);
return rows.length > 0;
}
async findDecryptedByIdForUser(
userId: string,
credentialId: number,
): Promise<CredentialRecord | null> {
const row = await this.findByIdForUser(userId, credentialId);
return this.decryptOne(row, userId);
}
async listDecryptedByUserId(userId: string): Promise<CredentialRecord[]> {
const rows = await this.listByUserId(userId);
return this.decryptMany(rows, userId);
}
async listFolders(userId: string): Promise<string[]> {
const rows = await this.context.drizzle
.select({ folder: sshCredentials.folder })
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId));
return [...new Set(rows.map((row) => row.folder).filter(Boolean))].sort();
}
async renameFolder(
userId: string,
oldName: string,
newName: string,
): Promise<number> {
const rows = await this.context.drizzle
.update(sshCredentials)
.set({ folder: newName })
.where(
and(
eq(sshCredentials.userId, userId),
eq(sshCredentials.folder, oldName),
),
)
.returning({ id: sshCredentials.id });
if (rows.length > 0) {
await this.afterWrite();
}
return rows.length;
}
async updateForUser(
userId: string,
credentialId: number,
update: CredentialUpdate,
): Promise<CredentialRecord | null> {
const rows = await this.context.drizzle
.update(sshCredentials)
.set(update)
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
)
.returning();
await this.afterWrite();
return rows[0] ?? null;
}
async updateEncryptedForUser(
userId: string,
credentialId: number,
update: CredentialUpdate,
): Promise<CredentialRecord | null> {
const userDataKey = DataCrypto.validateUserAccess(userId);
const encryptedUpdate = await this.encryptCredentialRecordForWrite(
update,
userId,
userDataKey,
);
const rows = await this.context.drizzle
.update(sshCredentials)
.set(encryptedUpdate)
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
)
.returning();
await this.afterWrite();
return this.decryptOne(rows[0] ?? null, userId);
}
async updateSystemEncryptionForUser(
userId: string,
credentialId: number,
update: CredentialSystemEncryptionUpdate,
): Promise<CredentialRecord | null> {
const rows = await this.context.drizzle
.update(sshCredentials)
.set(update)
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
)
.returning();
if (rows.length > 0) {
await this.afterWrite();
}
return rows[0] ?? null;
}
async deleteForUser(userId: string, credentialId: number): Promise<boolean> {
const rows = await this.context.drizzle
.delete(sshCredentials)
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
)
.returning({ id: sshCredentials.id });
await this.afterWrite();
return rows.length > 0;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
.delete(sshCredentials)
.where(eq(sshCredentials.userId, userId))
.returning({ id: sshCredentials.id });
if (rows.length > 0) {
await this.afterWrite();
}
return rows.length;
}
async recordUsage(
userId: string,
credentialId: number,
hostId: number,
usedAt = new Date().toISOString(),
): Promise<void> {
await this.context.drizzle.insert(sshCredentialUsage).values({
credentialId,
hostId,
userId,
usedAt,
});
await this.context.drizzle
.update(sshCredentials)
.set({
lastUsed: usedAt,
usageCount: sql`${sshCredentials.usageCount} + 1`,
})
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
);
await this.afterWrite();
}
private decryptOne<T extends Record<string, unknown>>(
record: T | null,
userId: string,
): T | null {
if (!record) return null;
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) return null;
return DataCrypto.decryptRecord(
"ssh_credentials",
record,
userId,
userDataKey,
);
}
private decryptMany<T extends Record<string, unknown>>(
records: T[],
userId: string,
): T[] {
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) return [];
return DataCrypto.decryptRecords(
"ssh_credentials",
records,
userId,
userDataKey,
);
}
private async encryptCredentialRecordForWrite<
T extends Record<string, unknown>,
>(record: T, userId: string, userDataKey: Buffer): Promise<T> {
const encryptedRecord = DataCrypto.encryptRecord(
"ssh_credentials",
record,
userId,
userDataKey,
);
const systemKey =
await SystemCrypto.getInstance().getCredentialSharingKey();
const systemEncrypted = await DataCrypto.encryptRecordWithSystemKey(
"ssh_credentials",
record,
systemKey,
);
return { ...encryptedRecord, ...systemEncrypted };
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,15 +0,0 @@
import { AlertRepository } from "./alert-repository.js";
import {
createCurrentRepositoryContext,
createCurrentRepositoryWriteHook,
} from "./current-repository-runtime.js";
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
export function createCurrentAlertRepository(): AlertRepository {
assertRepositoryRolloutDomainEnabled("alerts");
return new AlertRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("alert_repository_write"),
);
}
@@ -1,15 +0,0 @@
import { ApiKeyRepository } from "./api-key-repository.js";
import {
createCurrentRepositoryContext,
createCurrentRepositoryWriteHook,
} from "./current-repository-runtime.js";
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
export function createCurrentApiKeyRepository(): ApiKeyRepository {
assertRepositoryRolloutDomainEnabled("api_keys");
return new ApiKeyRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("api_key_repository_write"),
);
}
@@ -1,15 +0,0 @@
import { AuditLogRepository } from "./audit-log-repository.js";
import {
createCurrentRepositoryContext,
createCurrentRepositoryWriteHook,
} from "./current-repository-runtime.js";
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
export function createCurrentAuditLogRepository(): AuditLogRepository {
assertRepositoryRolloutDomainEnabled("audit_logs");
return new AuditLogRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("audit_log_repository_write"),
);
}
@@ -1,15 +0,0 @@
import { C2sTunnelPresetRepository } from "./c2s-tunnel-preset-repository.js";
import {
createCurrentRepositoryContext,
createCurrentRepositoryWriteHook,
} from "./current-repository-runtime.js";
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
export function createCurrentC2sTunnelPresetRepository(): C2sTunnelPresetRepository {
assertRepositoryRolloutDomainEnabled("c2s_tunnel_presets");
return new C2sTunnelPresetRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("c2s_tunnel_preset_repository_write"),
);
}
@@ -1,15 +0,0 @@
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
import {
createCurrentRepositoryContext,
createCurrentRepositoryWriteHook,
} from "./current-repository-runtime.js";
import { CommandHistoryRepository } from "./command-history-repository.js";
export function createCurrentCommandHistoryRepository(): CommandHistoryRepository {
assertRepositoryRolloutDomainEnabled("command_history");
return new CommandHistoryRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("command_history_repository_write"),
);
}
@@ -1,15 +0,0 @@
import { CredentialRepository } from "./credential-repository.js";
import {
createCurrentRepositoryContext,
createCurrentRepositoryWriteHook,
} from "./current-repository-runtime.js";
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
export function createCurrentCredentialRepository(): CredentialRepository {
assertRepositoryRolloutDomainEnabled("credentials");
return new CredentialRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("credential_repository_write"),
);
}
@@ -1,15 +0,0 @@
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
import {
createCurrentRepositoryContext,
createCurrentRepositoryWriteHook,
} from "./current-repository-runtime.js";
import { DashboardServiceLinkRepository } from "./dashboard-service-link-repository.js";
export function createCurrentDashboardServiceLinkRepository(): DashboardServiceLinkRepository {
assertRepositoryRolloutDomainEnabled("dashboard_service_links");
return new DashboardServiceLinkRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("dashboard_service_link_repository_write"),
);
}
@@ -1,15 +0,0 @@
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
import {
createCurrentRepositoryContext,
createCurrentRepositoryWriteHook,
} from "./current-repository-runtime.js";
import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
export function createCurrentDismissedAlertRepository(): DismissedAlertRepository {
assertRepositoryRolloutDomainEnabled("dismissed_alerts");
return new DismissedAlertRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("dismissed_alert_repository_write"),
);
}
@@ -1,15 +0,0 @@
import { FileManagerBookmarkRepository } from "./file-manager-bookmark-repository.js";
import {
createCurrentRepositoryContext,
createCurrentRepositoryWriteHook,
} from "./current-repository-runtime.js";
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
export function createCurrentFileManagerBookmarkRepository(): FileManagerBookmarkRepository {
assertRepositoryRolloutDomainEnabled("file_manager_bookmarks");
return new FileManagerBookmarkRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("file_manager_bookmarks_repository_write"),
);
}
@@ -1,15 +0,0 @@
import { HomepageItemRepository } from "./homepage-item-repository.js";
import {
createCurrentRepositoryContext,
createCurrentRepositoryWriteHook,
} from "./current-repository-runtime.js";
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
export function createCurrentHomepageItemRepository(): HomepageItemRepository {
assertRepositoryRolloutDomainEnabled("homepage_items");
return new HomepageItemRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("homepage_item_repository_write"),
);
}
@@ -1,15 +0,0 @@
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
import {
createCurrentRepositoryContext,
createCurrentRepositoryWriteHook,
} from "./current-repository-runtime.js";
import { HomepageLayoutRepository } from "./homepage-layout-repository.js";
export function createCurrentHomepageLayoutRepository(): HomepageLayoutRepository {
assertRepositoryRolloutDomainEnabled("homepage_layouts");
return new HomepageLayoutRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("homepage_layout_repository_write"),
);
}
@@ -1,15 +0,0 @@
import { HostFolderRepository } from "./host-folder-repository.js";
import {
createCurrentRepositoryContext,
createCurrentRepositoryWriteHook,
} from "./current-repository-runtime.js";
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
export function createCurrentHostFolderRepository(): HostFolderRepository {
assertRepositoryRolloutDomainEnabled("host_folders");
return new HostFolderRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("host_folder_repository_write"),
);
}
@@ -1,15 +0,0 @@
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
import {
createCurrentRepositoryContext,
createCurrentRepositoryWriteHook,
} from "./current-repository-runtime.js";
import { HostHealthRepository } from "./host-health-repository.js";
export function createCurrentHostHealthRepository(): HostHealthRepository {
assertRepositoryRolloutDomainEnabled("host_health");
return new HostHealthRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("host_health_repository_write"),
);
}
@@ -1,15 +0,0 @@
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
import {
createCurrentRepositoryContext,
createCurrentRepositoryWriteHook,
} from "./current-repository-runtime.js";
import { HostMetricsHistoryRepository } from "./host-metrics-history-repository.js";
export function createCurrentHostMetricsHistoryRepository(): HostMetricsHistoryRepository {
assertRepositoryRolloutDomainEnabled("host_metrics_history");
return new HostMetricsHistoryRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("host_metrics_history_repository_write"),
);
}
@@ -1,17 +0,0 @@
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
import {
createCurrentRepositoryContext,
createCurrentRepositoryWriteHook,
} from "./current-repository-runtime.js";
import { HostMetricsPreferenceRepository } from "./host-metrics-preference-repository.js";
export function createCurrentHostMetricsPreferenceRepository(): HostMetricsPreferenceRepository {
assertRepositoryRolloutDomainEnabled("host_metrics_preferences");
return new HostMetricsPreferenceRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook(
"host_metrics_preference_repository_write",
),
);
}

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