One terminal. Everything.
A modern, cross-platform SSH & remote session manager built with Electron, React, and xterm.js.
Ternix is a privacy-first desktop terminal and remote-session manager. It speaks SSH, Telnet, Serial, local shells, RDP, and VNC from a single interface, and bundles the tools you normally reach for separately: an SFTP file manager, an SSH key vault, port forwarding, session recording, a live system monitor, and command snippets.
It also reaches your terminal from your phone — a linked phone can open its own sessions or mirror one already running on the desktop, end-to-end encrypted, with no account and no third-party service holding your traffic.
Everything is stored locally in a SQLite database. Credentials are encrypted at rest with AES-256-GCM. Nothing is sent to a cloud service.
It ships as a native app for Linux (AppImage/deb) and Windows (NSIS installer/portable).
| Protocol | What Ternix does |
|---|---|
| SSH | Password, public-key, SSH-agent, keyboard-interactive (MFA/OTP), and none auth. Multi-hop jump-host chains (ProxyJump). Host-key pinning. Per-session keepalives and compression. Server banner display. Round-trip latency probe. Startup commands. Optional agent forwarding (under agent auth). |
| Telnet | Raw TCP with IAC option negotiation — NAWS (window size, re-sent on resize), terminal-type (xterm-256color), suppress-go-ahead, and echo. Auto-login on login: / password: prompt detection. |
| Serial / COM | Native serialport. Port enumeration with manufacturer names. Baud 300–921600, 7/8 data bits, 1/1.5/2 stop bits, parity (none/even/odd/mark/space), flow control (RTS/CTS or XON/XOFF). |
| Local shell | Native PTY via node-pty. Defaults to $SHELL (or /bin/bash) on Unix and $COMSPEC (or PowerShell) on Windows. Custom shell, args, cwd, and env supported. |
| VNC | Embedded in-pane viewer (noVNC) over a loopback, token-gated WebSocket↔TCP bridge. VNC password auth. Scales and centres to the pane. Falls back to a native client (vncviewer, xtigervncviewer, Remmina on Linux; TightVNC on Windows). |
| RDP | Embedded in-pane viewer (Apache Guacamole) through a local guacd daemon on Linux. guacd reachability is probed automatically; when it's unavailable — and always on Windows — Ternix hands off to a native client (mstsc, with credentials pre-stashed via cmdkey, or xfreerdp/wlfreerdp). guacd host/port configurable in Settings → Advanced. |
- xterm.js 5.5 with optional WebGL acceleration (auto-disabled when ligatures are on, since ligatures require the DOM renderer)
- Addons: fit, search, web-links (clickable URLs), Unicode 11 width
- Find with match-case and regex toggles, next/previous, and overview-ruler marks
- Context menu: Copy · Paste · Select All · Copy as HTML · Find… · Clear · Reset
- Right-click configurable: show the context menu, or paste immediately
- Paste safety: CRLF normalised to LF, optional multiline paste confirmation, optional trailing-whitespace trim
- Copy on select and middle-click paste, both optional
- Terminal bell: off, visual flash, or audio beep
- Font zoom with
Ctrl+/-/0 - Broadcast mode — type once in a floating bar, send to every pane of every broadcast-enabled tab
- Up to 6 panes per tab, tiled as at most 2 rows × 3 columns
- Drag the dividers to resize panes; each pane clamps to a minimum so none can be squeezed away. Sizes persist per tab.
- Split right (
Ctrl+Shift+D) or down (Ctrl+Shift+E); a new pane inherits the active pane's session - Split the tab strip into two side-by-side groups with an adjustable ratio — move a tab into the other group from its context menu, or drop it on a tab already there. While split, each side caps at 2×2 panes rather than 3×2.
- Tear a tab into its own window — from the context menu, or by dragging it out of the window. The connection is handed over live, not reconnected, so nothing running is interrupted. Drop it back onto another window's tab strip to re-adopt it.
- Tabs: drag to reorder, middle-click to close, rename,
Ctrl+1…Ctrl+9to jump - Tab context menu: duplicate tab (opens next to the original), rename, split right/down, move to group / new window, close tab / others / to the right
- Restore last session on startup, or open a blank tab, or open the session picker
- Auto-reconnect with configurable retries and delay;
Ctrl+Rto reconnect a dropped pane manually - The active pane is outlined when a tab holds more than one
Ctrl+P opens a fuzzy palette over every saved session plus quick actions (new SSH session, new local shell, split, toggle sidebar, open SFTP, key vault, broadcast, settings, import/export).
All 21 shortcuts below are rebindable in Settings → Keyboard (click a row, press the combo). Overrides are stored as JSON.
| Action | Default | Action | Default |
|---|---|---|---|
| New tab (local shell) | Ctrl+T |
Toggle SFTP panel | Ctrl+Shift+F |
| New SSH session | Ctrl+Shift+N |
Toggle broadcast | Ctrl+Shift+B |
| Close tab | Ctrl+W |
Increase font size | Ctrl+= |
| Next / previous tab | Ctrl+Tab / Ctrl+Shift+Tab |
Decrease font size | Ctrl+- |
| Split right | Ctrl+Shift+D |
Reset font size | Ctrl+0 |
| Split down | Ctrl+Shift+E |
Clear terminal | Ctrl+L |
| Toggle sidebar | Ctrl+B |
Disconnect | Ctrl+Shift+Q |
| Command palette | Ctrl+P |
Toggle recording | Ctrl+Shift+R |
| Find in terminal | Ctrl+F |
Open key vault | Ctrl+Shift+K |
| Open settings | Ctrl+, |
Full screen | F11 |
Ctrl+1…Ctrl+9 (jump to tab) and Ctrl+R (reconnect) are fixed.
- Unlimited sessions organised into nested group folders; drag a session onto a group to move it
- Fuzzy search across name, host, and tags
- Sort by A–Z, last connected, or protocol
- Duplicate a session in one click
- Metadata: name, protocol, host, port, username, tags, notes, environment variables, startup commands
- Right-click a session: Connect · Edit · Duplicate · Tunnels… · Connection log · Copy host · Delete
- Last connected timestamps
- Private keys encrypted at rest with AES-256-GCM
- Generate ed25519, RSA 4096, or ECDSA 521 keys in-app, with an optional passphrase (
aes256-cbc) - Import PEM / OpenSSH private keys by file picker or paste
- Scan
~/.sshto batch-import local keys (passphrase-protected keys are skipped — they can't be fingerprinted unattended) - Deploy a public key to any saved SSH session — the
ssh-copy-idequivalent - Copy public key, export private key, delete
SHA256:fingerprints and a "used by N sessions" count
Host keys are pinned in a known_hosts table (SQLite, not the OpenSSH file). Three modes via ssh.hostKeyStrictness:
- prompt (default) — unknown keys raise a dialog showing the fingerprint
- auto-accept — pin silently on first sight
- strict — refuse to connect to an unknown host
A changed host key always raises a warning dialog showing both the stored and the offered fingerprint, in every mode.
- Keychain mode (default) — a random 256-bit key is generated on first run and kept in your OS credential store (Windows Credential Manager, Linux Secret Service). The vault is always unlocked.
- Master-password mode — the key is derived with PBKDF2 (100,000 iterations, SHA-512) and never written anywhere. Setting, changing, or removing the master password re-encrypts every stored secret in a single transaction.
- Encrypted at rest: SSH passwords, key passphrases, VNC passwords, and private keys. Blobs are
[IV | GCM tag | ciphertext]with a fresh random IV per encryption. - Auto-lock on idle (0–1440 min) and on system sleep — master-password mode only
- Clipboard auto-clear after 0–600 s, and only if the clipboard still holds what Ternix copied
See Security Notes for exactly where the key lives in each mode. It matters.
- Stacked local and remote panes, each with its own scroller; the whole panel is resizable by a drag handle
- Browse, upload, download, mkdir, rename, delete (recursive on remote directories),
chmod - Columns for permissions and owner/group, parsed from the server's long listing
- Sort by default (server order), name, or date modified — re-picking the active sort returns to server order
- Toggle hidden files, editable path bar, up/refresh
- Multi-select with
Ctrl/Cmd-click,Shift-click ranges, andCtrl+A - Drag and drop between panes — and from your desktop / Explorer / Finder straight onto the remote pane to upload
- Dropping a folder uploads it recursively
- Double-click a remote file to download and open it locally
- Visual
chmodeditor — a 3×3 permission grid tied to a live octal field - Name-collision policy: prompt, overwrite, skip, or auto-rename
- Live progress, speed, and ETA per file
- Pause, resume, and cancel any individual file — cancelling one file in a 30-file folder transfer lets the other 29 finish
- Cancelling removes the partially-written file (local for downloads, remote for uploads) so a half-file is never mistaken for a real one
- Configurable parallelism (
transfer.maxConcurrent, default 3), applied across an entire folder tree rather than per-directory - Optional timestamp preservation
- Transfers move in ~254 KiB chunks — the maximum a single SFTP request allows
- Local (
-L), Remote (-R), and Dynamic (-D, SOCKS5) tunnels, saved per session - Auto-start on session connect
- Live bytes-transferred counter, status dot, copy address, stop, restart
- Reusable commands with name, description, and tags
${VAR}interpolation — each unique variable prompts once at run time- Multi-line snippets run line by line, each gated on the shell prompt returning, so long commands don't eat the next line as type-ahead
- Global snippets appear everywhere; unchecking "Global" scopes a snippet to the session that owns it
- Fuzzy search; JSON import/export
- Records terminal output as asciinema v2 (
.cast) - Built-in player: play/pause, restart, and a scrubbable timeline (idle gaps compressed to 2 s)
- Auto-record every session, optionally
- Storage cap that prunes the oldest recordings; export any
.cast
A live dashboard for the machine you're connected to — local or remote over SSH — polled every 3 seconds:
- CPU % with a heat-coloured sparkline, load averages, process count, and top process
- Memory and swap
- Per-mount disk usage
- Network RX/TX sparklines (bytes/sec)
- Uptime and temperature sensors
Remote stats are gathered by a small /proc probe over the SSH connection. While a session is still handshaking the panel says so rather than quietly showing your local machine's numbers.
Turn on Settings → Phone and Ternix serves a small terminal your phone opens in its browser. A linked phone can start its own sessions or mirror a pane already running on the desktop — both ends are views onto the same connection, so what you type on either appears on both.
- Two ways in. Local serves your Wi-Fi directly. Tunnel publishes through a Cloudflare quick tunnel so the phone works on mobile data —
cloudflaredis downloaded on first use rather than bundled, so the installer stays small. - Linking is a QR scan. The QR carries a 256-bit key in the URL fragment, which browsers never transmit — so the key reaches your phone without crossing the network at all. It is blurred on screen until you reveal it, single-use, and expires after two minutes.
- Everything is encrypted end to end, in both modes. See Security Notes for what that does and does not cover.
- The phone never talks to your servers. It sends keystrokes and receives text; the SSH connection is made by the desktop, and credentials never leave it.
- Sessions that would need a desktop prompt are filtered out — a phone has no way to answer a password, key, or host-key dialog, so those sessions say "open it once on the desktop" instead of connecting to a spinner that never resolves.
- Dropped connections don't kill work. A phone-opened pane survives 120 seconds without a socket and is reclaimed on reconnect, so walking into a lift doesn't end a running job. Mirrored desktop panes are never reaped by the phone.
- Scrollback replay on attach, so a mirrored pane isn't a blank screen until the next keystroke
- Quick commands — one-tap commands you'd rather not thumb-type, shown alongside your snippets
- Geometry is one-way: the phone scales to fit a mirrored pane rather than resizing it, so a phone can never reflow your desktop terminal
- Every linked phone is listed with when it was linked and last used, and can be unlinked instantly — revoking closes its live socket mid-session
- Every connect/disconnect is logged with host, timestamp, duration, and disconnect reason (last 500)
- Global Search view queries sessions and snippets together
Import from 7 formats — Ternix JSON backup, OpenSSH config, PuTTY .reg, WinSCP .ini, MobaXterm .mxtsessions, Tabby config.yaml, and generic CSV.
Export to 5 — Ternix JSON, CSV, OpenSSH config, MobaXterm, Tabby. (PuTTY and WinSCP are import-only.)
Smart key auto-linking — IdentityFile and other key paths are resolved against the vault by fingerprint first, then filename. Keys found on disk are imported automatically; keys that can't be located are recorded in the session's notes so nothing silently breaks. Duplicate sessions (same name + protocol + host + user) are skipped.
Exporting a backup with private keys requires the master password, when one is set.
12 built-in themes: Default Dark · Default Light · Dracula · Nord · Solarized Dark · Solarized Light · Tokyo Night · Gruvbox Dark · Monokai · One Dark Pro · Catppuccin Mocha · Catppuccin Latte
- Theme builder — edit background, foreground, cursor, selection, UI accent/surface/border, and all 16 ANSI colours, with a live preview. Import/export themes as JSON.
- Font family, size, ligatures, line height, letter spacing
- Cursor style (block/underline/bar) with optional blink
- Custom CSS injection, optional status-bar clock, compact mode
Every setting key and its default
| Section | Key | Default |
|---|---|---|
| General | general.defaultShell |
(empty) |
| General | general.startupBehavior |
blank (blank / reopen / picker) |
| General | general.newTabProtocol |
local |
| General | general.confirmCloseActive |
true |
| General | general.autoReconnect |
false |
| General | general.autoReconnectRetries |
3 |
| General | general.autoReconnectDelay |
3 |
| Terminal | terminal.scrollback |
5000 |
| Terminal | terminal.bell |
none (none / visual / audio) |
| Terminal | terminal.wordSeparators |
()[]{}'"` |
| Terminal | terminal.copyOnSelect |
false |
| Terminal | terminal.pasteOnMiddleClick |
true |
| Terminal | terminal.pasteConfirmMultiline |
true |
| Terminal | terminal.trimPasteWhitespace |
false |
| Terminal | terminal.rightClick |
menu (menu / paste) |
| Appearance | appearance.theme |
dark-default |
| Appearance | appearance.fontFamily |
JetBrains Mono, Fira Code, … |
| Appearance | appearance.fontSize |
14 |
| Appearance | appearance.ligatures |
false |
| Appearance | appearance.lineHeight |
1.2 |
| Appearance | appearance.letterSpacing |
0 |
| Appearance | appearance.cursorStyle |
block |
| Appearance | appearance.cursorBlink |
true |
| Appearance | appearance.compactMode |
false |
| Appearance | appearance.showClock |
false |
| Appearance | appearance.customCss |
(empty) |
| SSH | ssh.defaultPort |
22 |
| SSH | ssh.defaultUsername |
(empty) |
| SSH | ssh.agentSock |
(empty) |
| SSH | ssh.hostKeyStrictness |
prompt (strict / prompt / auto-accept) |
| SSH | ssh.connectTimeout |
20000 ms |
| SSH | ssh.showBanner |
true |
| Security | security.vaultLockTimeout |
0 min (0 = never) |
| Security | security.lockOnSleep |
false |
| Security | security.clearClipboard |
0 s (0 = never) |
| Transfers | transfer.downloadDir |
(empty → local pane's dir) |
| Transfers | transfer.conflict |
prompt (prompt / overwrite / skip / rename) |
| Transfers | transfer.maxConcurrent |
3 |
| Transfers | transfer.preserveTimestamps |
true |
| Recording | recording.autoRecord |
false |
| Recording | recording.maxStorageMb |
0 (0 = unlimited) |
| Phone | mobile.enabled |
false |
| Phone | mobile.port |
7717 |
| Phone | mobile.quickCommands |
[] (JSON) |
| Updates | updates.autoCheck |
true |
| Updates | updates.channel |
stable |
| Advanced | rdp.guacdHost |
127.0.0.1 |
| Advanced | rdp.guacdPort |
4822 |
| Advanced | advanced.hardwareAcceleration |
true |
| Advanced | advanced.rendererType |
webgl (webgl / canvas) |
| Advanced | advanced.debugLogLevel |
info |
Powered by electron-updater against GitHub Releases. Ternix checks on startup (when enabled) and surfaces a toast with a View button that jumps straight to Settings → Updates, where you can download with a live progress bar and restart to install.
The Beta channel opts you into GitHub pre-releases; Stable offers only full releases. The choice takes effect on the next check — no restart needed. Updates only work in packaged builds.
ternix/
├── electron/ # Main process (Node.js / Electron)
│ ├── main.ts # Window, CSP, power monitor, navigation lockdown
│ ├── preload.ts # Context-isolated IPC bridge (TernixApi)
│ ├── db/
│ │ ├── schema.ts # SQLite schema (WAL, foreign keys on)
│ │ ├── migrations/ # user_version-based forward migrations
│ │ ├── repo.ts # Repository layer
│ │ └── snippetScope.ts # Global vs session-scoped snippet invariant
│ ├── ipc/ # 20 IPC namespaces, {ok,data}/{ok,error} envelope
│ └── services/
│ ├── SshService.ts # SSH2 client: jump chains, host-key pinning, MFA
│ ├── SftpService.ts # SFTP ops + chunked transfers, pause/cancel
│ ├── TelnetService.ts # Telnet with IAC option negotiation
│ ├── SerialService.ts # serialport (lazy-loaded)
│ ├── PtyService.ts # Local PTY (node-pty)
│ ├── RdpGatewayService.ts # guacd gateway (guacamole-lite)
│ ├── VncBridgeService.ts # Token-gated loopback WebSocket↔TCP bridge
│ ├── NativeClientService.ts # mstsc / xfreerdp / vncviewer fallbacks
│ ├── TunnelService.ts # -L / -R / -D (SOCKS5)
│ ├── RecordingService.ts # asciinema v2 + storage cap
│ ├── KeyService.ts # Key vault: generate, import, deploy
│ ├── CryptoService.ts # AES-256-GCM vault + PBKDF2
│ ├── ImportExportService.ts # 7 import / 5 export formats
│ ├── MobileService.ts # Phone server: pairing, encrypted WS, pane bridge
│ ├── CloudflaredService.ts # Downloads/manages the cloudflared binary
│ ├── mobileCrypto.ts # Sealing + handshake shared with the phone client
│ ├── pairingGate.ts # Pairing lifecycle: single-use, TTL, burn on failure
│ ├── mobileGate.ts # Which sessions a phone may open unattended
│ ├── SpawnService.ts # The one place a connection is created
│ ├── ConnectionManager.ts # Active connection registry, OSC 7 cwd tracking
│ └── DatabaseService.ts # SQLite lifecycle
│
├── src/ # Renderer process (React + TypeScript)
│ ├── components/
│ │ ├── layout/ # RootLayout, ActivityBar, Sidebar, TabBar, StatusBar,
│ │ │ # TitleBar, CommandPalette, Toast, StatsPoller
│ │ ├── terminal/ # TerminalArea, TerminalPane, SplitLayout, BroadcastBar,
│ │ │ # TerminalSearch, TerminalToolbar, RemoteDesktopPane
│ │ ├── sidebar/ # SessionTree, GroupFolder, SessionCard, SnippetsPanel,
│ │ │ # TunnelsPanel, RecordingsPanel, StatsPanel,
│ │ │ # SearchPanel, SftpSidebar, TransferQueue
│ │ ├── sftp/ # SftpPanel, FileList, FileRow, PermissionsEditor
│ │ ├── settings/ # SettingsPanel + 10 sections (incl. Phone)
│ │ ├── dialogs/ # NewSession, KeyVault, Tunnel, ExportImport,
│ │ │ # RecordingPlayer, ThemeEditor, Snippet, ConnectionLog
│ │ └── ui/ # Modal, ContextMenu
│ ├── store/ # Zustand: tabs, sessions, settings, theme, sftp,
│ │ # transfers, stats, ui
│ ├── hooks/ # useTerminal, useSftp, useKeyboard, useTheme, useSsh
│ ├── themes/ # 12 built-in themes
│ └── utils/ # fuzzy, path, sftpSort, statsTarget, snippets, format*
│
├── resources/mobile/index.html # The phone client — one self-contained page
├── electron-builder.json # AppImage + deb (Linux), NSIS + portable (Windows)
└── electron.vite.config.ts
better-sqlite3, synchronous, in the main process — no races on credential storage. WAL journal, foreign keys on,user_versionmigrations applied in a transaction.ssh2for the full protocol: multi-hop jump hosts, the SFTP subsystem, exec channels, and the latency probe.- SFTP transfers use ~254 KiB chunks (
OPENSSH_MAX_PKT_LEN − PKT_RW_OVERHEAD). ssh2's SFTP streams issue one request and wait for the reply, so on a high-latency link the chunk size is the throughput. node-ptyfor local shells with real signal handling;serialportloaded lazily so the app still boots if its native build failed.- AES-256-GCM with a random IV per encryption; blobs stored as
[IV | tag | ciphertext]. - Zustand for state — the transfer queue subscribes per row, so a progress tick re-renders one row rather than the whole list.
- The renderer never touches Node. Everything goes through one typed
contextBridgesurface; each IPC call returns an{ok, data}/{ok, error}envelope and pings the vault idle timer. - Nothing blocks the main process. System-stats collection is async with TTL caches, because Electron routes your input events through that process.
Download the .exe installer or the portable build from Releases.
Download the .AppImage or .deb from Releases.
For embedded RDP, install guacd (the .deb declares it as a dependency). Without it, RDP falls back to a native client.
- Node.js 20+ (CI builds on 22)
- Native modules compile via node-gyp, so you need a toolchain:
- Linux:
libsecret-1-dev(keytar),libudev-dev(serialport);rpmandlibarchive-toolsto package - Windows: Python with
setuptools(the distutils shim node-gyp needs on Python 3.12+)
- Linux:
git clone https://github.com/PraneethReddy-github/ternix.git
cd ternix
npm install
npm run devIf you hit native module errors (node-pty, better-sqlite3, serialport, keytar):
npm run rebuildnpm run build:linux # AppImage + .deb → dist-electron/linux
npm run build:win # NSIS + portable → dist-electron/winnpm run typecheck # tsc across renderer + main
npm run lint # eslint over .ts / .tsxTernix has no test framework. Instead, non-trivial pure logic keeps a runnable self-check beside it — plain assert, no fixtures. Run them with Node's type stripping:
node --experimental-strip-types electron/db/snippetScope.check.ts
node --experimental-strip-types electron/services/sftpOwner.check.ts
node --experimental-strip-types electron/services/transferOutcome.check.ts
node --experimental-strip-types electron/services/vaultKeyPlan.check.ts
node --experimental-strip-types electron/services/mobileGate.check.ts
node --experimental-strip-types electron/services/pairingGate.check.ts
node --experimental-strip-types electron/services/mobileCrypto.check.ts
node --experimental-strip-types electron/services/mobileCrypto.interop.check.ts
node --experimental-strip-types src/utils/sftpSort.check.ts
node --experimental-strip-types src/utils/statsTarget.check.tsmobileCrypto.interop.check.ts is the odd one out: it lifts the phone client's crypto helpers straight out of resources/mobile/index.html and runs them against the desktop's, so the browser and Node implementations can't silently drift apart.
Ternix keeps every credential on your machine. Here is precisely how.
SSH passwords, key passphrases, VNC passwords, and stored private keys are encrypted with AES-256-GCM. Each blob is laid out as [12-byte IV | 16-byte auth tag | ciphertext] with a fresh random IV per encryption, so identical secrets never produce identical ciphertext, and any tampering fails the GCM tag check on decrypt.
Hosts, usernames, ports, notes, tags, and snippet commands are not encrypted — they are ordinary columns in the SQLite database.
Everything above is encrypted with one 256-bit vault key. There are two modes.
Keychain mode (default). The key is generated on first run and stored in your operating system's credential store:
| Platform | Backing store |
|---|---|
| Windows | Credential Manager |
| Linux | Secret Service (libsecret / GNOME Keyring / KWallet) |
Ternix writes the key, then reads it back and compares before trusting the store. On every launch the key is read from the keychain — it is not kept on disk.
If the machine has no credential store at all (a headless Linux box without libsecret, or a failed keytar build), Ternix falls back to a 0600 key file at <userData>/.vaultkey. That fallback is the only situation in which the key touches the filesystem, and in that case the file protects the vault exactly as well as your user account does.
Master-password mode. The key is derived from your password with PBKDF2 (100,000 iterations, SHA-512) against a stored random salt, and is never written anywhere — not the keychain, not the disk. It exists only in memory while the vault is unlocked. Switching modes re-encrypts every stored secret in a single database transaction, and old keys are zeroed in memory afterwards.
Your password itself is never stored. It is verified by decrypting a known verifier string: the wrong password fails the GCM auth tag.
Master-password mode locks the vault on an idle timeout and on system sleep, zeroing the key. Keychain mode is always unlocked — the key is available whenever the app runs, so the idle and sleep settings do nothing there. Choose master-password mode if you want a vault that actually locks.
Everything between the desktop and a linked phone is sealed with XSalsa20-Poly1305 under a key only those two hold. This runs inside the transport, so it applies identically on a plain-HTTP LAN address and through the Cloudflare tunnel.
How the key gets there. The pairing QR encodes a 256-bit secret in the URL fragment, and browsers never send fragments to a server. Scanning is therefore the entire key exchange — nothing equivalent ever crosses the network, so there is no handshake to capture and nothing short enough to guess. The phone proves it scanned by sealing its pairing request under that secret; the desktop replies with a per-device link key, sealed the same way. Pairings are single-use, expire in 120 seconds, and burn after 5 failed attempts.
Per-connection keys. Each WebSocket begins with the phone sealing an ephemeral X25519 public key under its device key. That one frame both identifies the device and starts a fresh exchange, so traffic is encrypted under a key that exists only for that connection — a recording made today stays unreadable even if the device key leaks later. The device key is never sent over the network after pairing; it only ever proves itself by decrypting.
Nothing identifying travels in the clear either: there is no token in the WebSocket URL (query strings are the part of a request proxies and access logs keep), and the desktop identifies a phone by finding which stored key opens the frame rather than reading an id off the wire.
What this does not cover. The encryption is delivered by JavaScript that the phone fetches over the same channel. An attacker who can modify traffic in real time — not merely observe it — could tamper with that page as it loads, and no amount of payload encryption fixes that. On a LAN that means anyone positioned on your network; through the tunnel it means Cloudflare, who serve the page. Passive observation is defeated completely in both cases; this is strictly about active tampering. Real TLS is the only fix, and a LAN IP can never hold a certificate a browser trusts. Prefer Tunnel mode on networks you don't control.
Also unprotected, and unfixable by design: the existence and volume of the connection is visible to anyone on the path, and anyone who photographs the QR while it is displayed can link their own phone — which is why it stays blurred until you reveal it, and why every pairing shows up in the device list.
Device link keys are stored unencrypted in the settings table. Both ends must hold the key in the clear to encrypt with it, so unlike a bearer token it cannot be stored hashed. Anyone who can read that database already has your user account, and with it your SSH keys.
- Exporting a private key prompts for the master password only when one is set.
- Host keys are pinned on first use; a changed key always raises a warning showing both the stored and the offered fingerprint.
- Embedded RDP does not validate server certificates (
ignore-cert). Treat in-pane RDP as protected by the network path, not by TLS identity. - The renderer never touches Node:
contextIsolationis on,nodeIntegrationis off,webviewTagis off, in-app navigation is blocked,window.openis denied, and a Content-Security-Policy is injected on every response. - Clipboard entries copied by Ternix can auto-clear after a timeout, and only if the clipboard still holds what Ternix put there.
Documented so you don't discover them the hard way:
- Compact mode only hides the per-pane toolbar.
- Per-session terminal encoding is stored but not applied — SSH is UTF-8, Telnet and Serial are byte-transparent.
- X11 forwarding,
server_alive_interval, and RDP colour-depth/fullscreen are exposed in the session editor but not yet wired to the connection. - Agent forwarding only takes effect when the session's auth type is agent.
- Session, group, and tab accent colours exist in the data model but no UI sets them yet.
- Accepting a changed host key does not overwrite the stored pin, so you'll be asked again next connect.
- Embedded RDP is Linux only; Windows always uses
mstsc. ~/.sshscanning skips passphrase-protected keys (they can't be fingerprinted without the passphrase).- A phone pairs per address, not per device. Browser storage is scoped to the origin, so a phone linked over Wi-Fi is not linked over the tunnel, and each pairing adds its own entry to the device list.
- Cloudflare quick tunnels get a new URL every start, which by the point above means re-pairing each time — and re-pairing needs you in front of the desktop to scan. Start the tunnel before you leave, or use a named tunnel (not yet supported in-app).
- Phone access can't answer connection prompts. Sessions needing a password, an SSH key, or host-key confirmation must be opened once on the desktop first.
- macOS is not supported. It has never been tested, nothing is built or released for it, and no macOS artifacts exist.
| Package | Purpose |
|---|---|
@xterm/xterm + addons (fit, search, web-links, webgl, unicode11) |
Terminal emulator |
ssh2 |
SSH2 protocol client + SFTP |
node-pty |
Local shell PTY |
serialport |
Serial / COM |
better-sqlite3 |
Synchronous SQLite |
keytar |
OS keychain (optional) |
guacamole-lite + guacamole-common-js |
Embedded RDP via guacd |
@novnc/novnc + ws |
Embedded VNC over a loopback bridge |
tweetnacl |
Phone-link encryption (X25519 + XSalsa20-Poly1305) |
qrcode |
Pairing QR for phone access |
js-yaml |
Tabby YAML import/export |
react + react-dom + zustand |
UI and state |
lucide-react |
Icons |
electron-updater (optional) |
Auto-update against GitHub Releases |
Contributions are welcome — please open an issue first to discuss substantial changes.
- Fork the repository
- Create your branch (
git checkout -b feature/amazing-feature) - Run
npm run typecheckand the relevant*.check.tsself-checks - Commit (
git commit -m 'feat: add amazing feature') - Open a Pull Request
MIT — see LICENSE.
Built with ❤️ using Electron, React, and xterm.js
>T<