A local FastAPI-powered SSH multiplexer for tmux-in-browser — from your laptop only.
Project description
sshler
sshler is a lightweight, local-only web UI that lets you browse remote files over SFTP and jump into tmux sessions in your browser — without installing anything on the remote host.
Quick Start
# Install
pip install sshler
# Run (opens browser automatically)
sshler serve
# Or with development setup
uv sync --group dev
cd frontend && pnpm install && pnpm build && cd ..
sshler serve
The app opens at http://127.0.0.1:8822 and redirects to the Vue SPA at /app/.
Features
Core Features
- Cross-platform: Runs on Windows 11, macOS, and Linux (anywhere with Python 3.12+)
- Local workspace: Browse your own filesystem and launch native tmux sessions alongside remote hosts (uses WSL tmux on Windows, native tmux on Linux/macOS)
- SSH integration: Uses your existing SSH keys and honors OpenSSH aliases
- Terminal in browser: Opens
tmux new -As <session> -c <dir>on the remote host and bridges it via WebSocket + xterm.js - File management: Vue-based file browser with preview, edit, delete, and "Open Terminal Here"
- Auto-configuration: Creates starter config on first run
- Alias resolution: Falls back to
ssh -Gwhen DNS fails; reset overrides with one click - File operations: Preview, edit (≤256 KB), and delete files with CodeMirror editor
Modern UI Features
🎨 Theme & Appearance
- Dark/Light Theme Toggle - Seamless theme switching with system preference detection
- PWA Support - Install as a standalone app with offline capabilities and app icons
⌨️ Keyboard & Navigation
- Command Palette (Cmd/Ctrl+K) - Quick access to all features with fuzzy search
- Keyboard Shortcuts - Press
?to see all available shortcuts - Global Search (Cmd/Ctrl+Shift+F) - Search across all files in all boxes
📁 Enhanced File Management
- Drag & Drop Upload - Drop files directly into the file browser
- Bulk Operations - Select multiple files with Shift+Click and Cmd/Ctrl+Click
- Inline Rename (F2) - Rename files without opening a modal
- Context Menus - Right-click for quick actions
- Recent Files & Bookmarks - Quick access to frequently used locations
- File Preview Enhancements - Toggle line numbers and word wrap in file viewer
- Directory Download - Download any directory as a .zip with size warnings for large dirs
🔀 Diff Notebook
/app/diff— multi-cell diff workspace - Stack N file diffs vertically and review them all at once. Each diff is one(left ↔ right)file pair; sides are fully independent (different boxes, repos, refs). Auto-loads as you type; no Compare button.- Command bar -
:add,:rm 2,:swap 1 3,:repo local /path,:clear. Side syntax:box:dir@ref:path. Presscto focus from anywhere,?for help,j/kto scroll between diffs. - Shareable URLs -
?n=<base64>for self-contained share-by-paste, or hit Save & share for a short/app/diff/n/<id>URL backed by SQLite. Saves are immutable — editing a shared notebook forks. Open the Saved drawer to manage server saves + local recents. - See
docs/diff-notebook.mdfor the full user guide.
📝 Markdown Preview & PDF Export
- One-Click PDF Download (optional) - With the
[pdf]extra installed, every text file gets a PDF button. Works from the preview modal, files-list right-click, and the multi-select toolbar (renders selection one file at a time). Backend uses headless Chromium via Playwright, so output matches the print preview pixel-for-pixel — mermaid SVGs stay vector, code blocks stay crisp. See Install below to enable. - Live Markdown Render - Toggle source/rendered view on any
.mdfile via the eyeball button - Mermaid Diagrams - Flowcharts, sequence diagrams, state diagrams, timelines, class diagrams etc. render inline from
```mermaidfenced blocks. Mermaid is lazy-loaded — no bundle cost unless you actually view a markdown file with diagrams. - Embedded Images -
andresolve against the file's directory and are fetched via the preview API, then rewritten todata:URLs. Works for any image format the backend recognises (png/jpg/gif/webp/svg/etc.). Absolute remote paths (/srv/docs/foo.png) also work. Inlined images render in the modal AND survive being cloned into the print window — printed PDFs include them. Oversized images (above the preview byte limit) stay broken, same as opening them directly.http(s)://URLs are left alone — they load in the modal but may not appear in print depending on the print window's network access. - HTML Entity Decode - Source authors can write
</≥/&in mermaid blocks (common dodge for markdown's<handling); they're decoded before mermaid sees them so the lexer doesn't choke. - Print to PDF - Printer-icon button in Render mode opens a styled print window; pick "Save as PDF" in the browser print dialog. Mermaid SVGs print as vectors, stay crisp at any zoom.
- Smart Print Layout - A4 portrait with 18mm margins. Tall diagrams shrink to fit one page (max-height 230mm). Wide diagrams (aspect ratio > 1.4:1) auto-rotate to landscape on their own page. Print-mode CSS forces white background + black text regardless of app theme.
- Page-Break Awareness -
page-break-inside: avoidon code blocks, blockquotes, mermaid diagrams, and tables keeps them from splitting across pages. - Inline HTML in Markdown - Authored
<div>/<span>blocks with inline styles (banners, callouts, badges) render in preview AND print.print-color-adjust: exactis forced so colored backgrounds aren't silently dropped by the browser's default toner-saving behavior. - Theme-Independent Print - Print output always uses a light palette regardless of whether you're viewing in dark mode — avoids muddy grey-on-grey headings and table headers.
- Print Any Text File - The Print button in the preview modal works on any non-image file. Markdown goes through the rendered-with-diagrams path; code/text files print as monospace source with the file path in a header.
- One-Click Print from File List - Right-click any file → Print. Opens the preview modal and auto-fires the print dialog once content loads — no manual Render → Print step.
🖥️ Terminal Features
- Multi-pane Layouts - Split terminal horizontally, vertically, or in a grid
- Session Persistence - Restore your terminal layout on reload
- Crash Recovery - Periodic snapshots of tmux window state (every 30s); if WSL/system crashes, a recovery modal offers to recreate your sessions with the same window layout and working directories
- Live Dead-Session Detection - Detects OOM-killed or crashed tmux sessions without needing a restart; recovery modal appears automatically
- Snapshot Freshness Indicator - Blue dot next to the connection indicator pulses when snapshots are active, fades to grey as they age
- Clickable File Paths - File paths and
file://URLs in terminal output are clickable links; file:// URLs copy to clipboard with a toast - Terminal Notifications - Desktop notifications for long-running commands
- Connection Status - Real-time connection health indicators
- Command Snippets - Save and quick-insert frequently used commands per box or globally
- Port Forwarding - Visual SSH tunnel management (local/remote) per box
- Per-Box Terminal Themes - Color-code terminals by environment (prod=red, staging=green, etc.)
- Per-Box Emoji Icons - Deterministic emoji assigned per box for quick visual identification
- Active Box Context - Navigation links remember your current box when switching between views
📱 Mobile & Touch Support
- Touch-Optimized - 44px minimum touch targets for easy tapping
- Swipe Gestures - Swipe right to navigate back in file browser
- Long-Press Context Menu - Long-press files for quick actions (500ms)
- Pull-to-Refresh - Pull down to reload the current directory
- Responsive Design - Optimized layouts for tablets and phones
- Virtual Keyboard Support - Terminal automatically adjusts when mobile keyboard appears
- Orientation Change - Smooth terminal resize when rotating device
- iOS Input Optimization - 16px font size prevents auto-zoom on focus
- Passive Touch Events - Smooth scrolling with no jank
- Mobile Fullscreen - Minimal UI in fullscreen for maximum typing space
📱 Mobile Terminal Input Bar
- Quick Keys - Phosphor icon buttons for keys hard to type on mobile
- Arrow Navigation - ▲▼◀▶ for menu navigation (Claude Code, vim, etc.)
- Enter/Tab - Confirm selections and autocomplete
- Escape/Stop - Interrupt Claude Code turns or cancel operations (yellow)
- Ctrl+C - Kill processes (red - danger indicator)
- Tmux Scroll Mode - 📜 enters copy mode, ⏫⏬ for page up/down (orange group)
- Ctrl+D - Graceful exit/EOF (teal)
- Help Legend - Tap
?to see what each button does - Color-Coded - Visual grouping by function (blue=confirm, red=danger, orange=scroll)
📱 Ultra-Thin Mobile Header
- 14px Height - Maximum terminal real estate (JuiceSSH-inspired)
- Live Stats - CPU/MEM percentages with color indicators (green/orange/red)
- Minimal Chrome - Just logo and stats, no buttons
📊 Global Progress Bars
- Push from any script -
sshler progress push <name> <current> <total>from a build/CI/deploy script, the bar appears in the browser instantly. No setup, no project to configure — uses your running sshler instance. - Watch from anywhere - A thin strip under the header shows the current box's bars on every page. Files, Terminal, Settings, doesn't matter — the bars are always in view.
- Per-box subscriptions - Bars exist server-side (global pool); each box keeps its own view of them. Subscribe
deploywhile you're on thesshlerbox, switch tomaintenance, and the strip clears — switch back and it returns. Hit the+on the strip to open a picker and toggle bars for the current box. The/app/progressmanagement page lists every bar with per-row subscribe toggles (scoped to the active box), delete buttons, and stale-row highlighting. - Bars stay until you dismiss them - Subscribed bars persist on the strip through every status, including
done(the green check is the payoff of subscribing). Clear one with the picker, by unsubscribing, or by deleting it from the management page. A bar flashes white once the moment it finishes. - Rich tooltips + metadata - Hover any bar for a tooltip with its full label, status, count, timestamp, and any custom metadata a script attached (
--meta key=value). Metadata is fault-tolerant — if a script sends garbage, the bar keeps advancing and just shows a small error note instead of breaking. - Honest percentages - The displayed percent is rounded down: a 3300-step build at 3299/3300 reads 99%, never a misleading 100% until it's actually done.
- Live updates - One WebSocket (
/ws/progress) fans out every upsert/delete to every connected browser tab. Multi-machine? Open/app/progresson your phone too.
♿ Accessibility
- WCAG 2.1 AA Compliant - Semantic HTML, ARIA labels, keyboard navigation
- Screen Reader Support - Proper focus management and announcements
- Reduced Motion - Respects
prefers-reduced-motionsystem setting - High Contrast - Clear visual hierarchy and color contrast
Install
PyPI (recommended)
pip install sshler
# Launch once to create the config + systemd/service assets
sshler serve
Requires Python 3.12+.
Optional: PDF export
The PDF download buttons appear only when Playwright + headless Chromium are available. To enable:
pip install "sshler[pdf]"
playwright install chromium
# (one-time, on Linux you may also need: playwright install-deps chromium)
The base install works fine without this — sshler runs normally, the PDF buttons just stay hidden.
Development
uv pip install -e .
# or: pip install -e .
After cloning the repository, install the dev extras and run the usual tooling:
uv sync --group dev
uv run ruff check .
uv run pytest
E2E smoke test (Playwright):
uv run playwright install chromium # one-time browser download
uv run pytest tests/e2e
# or reuse the project venv: .venv/bin/pytest tests/e2e/test_vue_app.py
Run
sshler serve
The app will open http://127.0.0.1:8822 in your default browser and redirect to /app/.
Building the Frontend
The Vue SPA must be built before running (pre-built in PyPI releases):
cd frontend && pnpm install && pnpm build
# or use the CLI:
sshler build
Development Mode
For hot-reload development:
# Terminal 1: Backend
sshler serve --no-browser
# Terminal 2: Frontend dev server
cd frontend && pnpm dev -- --host --base /app/
# Visit http://localhost:5173/app/
Or use the combined dev command:
sshler dev # Runs both servers with hot-reload
Pushing progress bars
Any script on the same host can push a progress bar that shows up live in the browser:
sshler progress push build 0 100 --label "frontend build" --color blue
# ... your work ...
sshler progress push build 50 100 --color blue --meta stage=compile
sshler progress push build 100 100 --status done --color green
sshler progress list
sshler progress delete build
Available flags: --label TEXT, --color NAME|#hex, --status running|done|failed|cancelled, --url URL, --token TOKEN.
Metadata — attach arbitrary fields shown in the bar's hover tooltip: --meta KEY=VALUE (repeatable), or --meta-json '{"warnings":3}'. By default each push replaces the metadata bag; use --merge to add to it, --clear-meta to empty it. A push with no metadata flag leaves existing metadata untouched, so a tight progress loop won't wipe it. Malformed metadata never blocks the push — the bar still advances and the tooltip shows the error.
Names must match ^[A-Za-z0-9._:-]{1,64}$ (push to the same name = update the same bar). Color names recognised: blue green red yellow orange purple pink teal; any CSS color (#3b82f6, rgb(...)) also works.
Token discovery — When sshler starts, it writes the active CSRF token to <config_dir>/runtime-token (~/.config/sshler/runtime-token on Linux, mode 0600). The CLI auto-reads it, so local pushes need no setup. Remote / cross-machine pushers can override via --token or $SSHLER_TOKEN. URL discovery similarly defaults to http://127.0.0.1:8822, overridable via --url / $SSHLER_PROGRESS_URL.
Subscriptions are per box: open a box (Files/Terminal), then hit the + on the header strip — or use /app/progress — to subscribe to the bars you want. Each box remembers its own set, so sshler and maintenance can show different bars. The thin strip under the header renders the active box's subscribed bars on every page.
See examples/progress-bar-build-watcher.sh for a copy-pasteable demo loop including trap → --status failed on error.
Key Shortcuts
- Cmd/Ctrl+K - Command palette
- Alt+F - Go to Files
- Alt+T - Go to Terminal
- Alt+B - Go to Boxes
- ? - Show all keyboard shortcuts
Configuration
sshler reads your existing OpenSSH config (~/.ssh/config) and shows every concrete Host entry automatically. Any favourites, default directories, or custom hosts you add through the UI are stored in a companion YAML file.
A config file is created on first run:
- Windows:
%APPDATA%\sshler\boxes.yaml - macOS/Linux:
~/.config/sshler/boxes.yaml
Example:
boxes:
- name: my-server
host: server.example.com # literal IP/FQDN
ssh_alias: my-server # optional: resolves via `ssh -G my-server`
user: alice
port: 22
keyfile: ~/.ssh/id_ed25519
favorites:
- /home/alice
- /home/alice/projects
- /var/www
default_dir: /home/alice
Tip: Set
default_dirif your home path isn't/home/<user>. If you rely on an OpenSSH alias, addssh_alias:and sshler will runssh -Gto expand it when DNS fails.
Resetting overrides
Boxes imported from SSH config show a highlighted border and "Refresh" button. If you change something in ~/.ssh/config, hit Refresh to drop any stored overrides (host/user/port/key) so the new settings take effect without editing boxes.yaml.
Adding custom boxes
Hit "Add Box" in the UI to define a host that isn't in your SSH config (for example, a throwaway Docker container). Fields you leave blank fall back to your SSH defaults.
Security model (important)
Localhost (127.0.0.1): No password required. sshler binds to localhost by default and uses a random X-SSHLER-TOKEN for CSRF protection.
Non-localhost: Password REQUIRED. If you bind to 0.0.0.0 or any non-localhost address, you MUST configure authentication:
# Set up password (recommended - creates hash in .env)
sshler hash-password
# Or use environment variables directly
export SSHLER_USERNAME=admin
export SSHLER_PASSWORD_HASH='$argon2id$...' # Use sshler hash-password to generate
# Or use CLI flag (not recommended - visible in process list)
sshler serve --host 0.0.0.0 --auth myuser:mypassword
Additional security notes:
- Environment variables: Never commit your
.envfile to version control. Use.env.exampleas a template. The.envfile may contain sensitive credentials like password hashes. - File uploads are capped at 50 MB (tunable via
--max-upload-mb). Uploaded content is never executed server-side. - SSH connections still honour your system
known_hosts. Only setknown_hosts: ignoreif you fully understand the risk. - If you expose sshler beyond localhost, opt-in via
--allow-originand add--auth user:pass(basic auth). Use it only on networks you trust and put TLS in front (nginx, Caddy, etc.). - There is no telemetry, analytics, or call-home behaviour.
CLI options
sshler serve \
--host 127.0.0.1 \
--port 8822 \
--max-upload-mb 50 \
--allow-origin http://workstation:8822 \
--auth myuser:mypassword \
--no-ssh-alias \
--log-level info
--host(alias--bind) sets the bind address (default:127.0.0.1for localhost-only). Use0.0.0.0to expose on all interfaces, but only on trusted networks with--authand TLS.--portsets the port number (default:8822).--allow-origincan be repeated to expand CORS; combine it with--authif you expose the UI beyond localhost.--auth user:passenables HTTP basic authentication (recommended if binding to0.0.0.0).--max-upload-mbsets the upload size limit (default: 50 MB).--no-ssh-aliasdisables thessh -Gfallback when DNS fails.--tokenlets you supply your ownX-SSHLER-TOKEN(otherwise a secure random value is generated).--log-levelfeeds directly into uvicorn (options:critical,error,warning,info,debug,trace).
The server prints the token (and, if enabled, the basic auth username) on startup so you can copy it into API clients or browser extensions.
Terminal notifications
- Send a bell (
printf '\a') from tmux or your shell to flash the browser title and raise a desktop notification whenever the sshler tab is hidden. - For richer messages use OSC 777:
printf '\033]777;notify=Codex%20done|Check%20the%20output\a'. The text before the|becomes the title; the second part is the body. - JSON payloads are also supported:
printf '\033]777;notify={"title":"Codex","message":"All tasks finished"}\a'. - The first notification prompts the browser for permission. Denying it still leaves the in-app toast and title badge when you return to the tab.
TLS/HTTPS Deployment
Why HTTPS Matters
sshler uses secure httpOnly session cookies for authentication. While these cookies provide strong security, browsers require the Secure flag to be set on cookies when serving over HTTPS. This ensures cookies are only transmitted over encrypted connections.
For production deployments, HTTPS is strongly recommended.
Deployment Options
1. Localhost Development (HTTP)
For local development on localhost or 127.0.0.1, you can disable the Secure cookie flag:
# .env
SSHLER_HOST=127.0.0.1
SSHLER_PORT=8822
SSHLER_PUBLIC_URL=http://localhost:8822
SSHLER_COOKIE_SECURE=false # Only for localhost dev!
⚠️ Never use COOKIE_SECURE=false in production or on network-accessible interfaces.
2. Production with Caddy Reverse Proxy (Recommended)
Caddy is the easiest way to add HTTPS to sshler. It automatically obtains and renews Let's Encrypt certificates.
Basic Setup:
-
Install Caddy:
# Ubuntu/Debian sudo apt install caddy # macOS brew install caddy
-
Create a Caddyfile:
# /etc/caddy/Caddyfile or ~/Caddyfile sshler.company.internal { reverse_proxy localhost:8822 } -
Configure sshler for HTTPS:
# .env SSHLER_HOST=127.0.0.1 SSHLER_PORT=8822 SSHLER_PUBLIC_URL=https://sshler.company.internal SSHLER_COOKIE_SECURE=true # Required for HTTPS
-
Start Caddy:
# System service sudo systemctl start caddy # Or run directly caddy run --config /etc/caddy/Caddyfile
-
Access sshler at
https://sshler.company.internal
For LAN Deployments (Self-Signed Certs):
If you're deploying on a local network without a public domain, use Caddy with a self-signed certificate:
sshler.local {
tls internal # Use Caddy's internal CA
reverse_proxy localhost:8822
}
Then configure your browser to trust Caddy's local CA certificate (usually at ~/.local/share/caddy/pki/authorities/local/root.crt).
Advanced Caddy Configuration:
sshler.company.internal {
# Automatic HTTPS with Let's Encrypt
# Optional: Rate limiting for API endpoints
@api {
path /api/v1/*
}
rate_limit @api 100r/m
# Stricter rate limiting for login endpoint (recommended)
@login {
path /api/v1/auth/login
}
rate_limit @login 5r/m
# Proxy to sshler
reverse_proxy localhost:8822 {
# Preserve client IP
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
# Optional: Add security headers
header {
Strict-Transport-Security "max-age=31536000;"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "no-referrer"
}
}
3. Tailscale Deployment
If you're using Tailscale, you can access sshler over your Tailscale network. Tailscale automatically provides HTTPS with MagicDNS.
-
Configure sshler to listen on your Tailscale IP:
# .env SSHLER_HOST=100.64.0.1 # Your Tailscale IP SSHLER_PORT=8822 SSHLER_PUBLIC_URL=https://yourhost.tail-scale.ts.net SSHLER_COOKIE_SECURE=true
-
Enable Tailscale Serve (optional, for HTTPS):
tailscale serve https / http://localhost:8822
-
Access sshler at
https://yourhost.tail-scale.ts.net
Note: Tailscale provides network-level encryption, but using HTTPS ensures secure cookies work properly.
4. Other Reverse Proxies
Nginx:
server {
listen 443 ssl http2;
server_name sshler.company.internal;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:8822;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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 $scheme;
proxy_cache_bypass $http_upgrade;
}
}
Traefik:
http:
routers:
sshler:
rule: "Host(`sshler.company.internal`)"
service: sshler
tls:
certResolver: letsencrypt
services:
sshler:
loadBalancer:
servers:
- url: "http://localhost:8822"
Multi-Instance Deployments
⚠️ IMPORTANT: The current session store is in-memory and not suitable for multi-instance deployments (e.g., behind a load balancer with multiple sshler processes).
Why this matters:
- Sessions are stored in process memory
- Each instance has its own independent session store
- Users will lose their session if requests are routed to a different instance
- Session cookies will appear invalid when load-balanced across instances
For single-instance deployments (most common):
- ✅ One sshler process behind a reverse proxy (Caddy, Nginx)
- ✅ Systemd service running one instance
- ✅ Docker container (single instance)
For multi-instance/load-balanced deployments, you must implement a shared session backend:
Option 1: Redis (Recommended for Production)
# Replace SessionStore with Redis-backed implementation
# See sshler/session.py for the interface to implement
Option 2: Database (PostgreSQL, MySQL)
# Implement SessionStore backed by a database table
# Ensure all instances connect to the same database
Option 3: Sticky Sessions (Not Recommended)
- Configure load balancer for session affinity based on cookie
- Still requires graceful handling of instance failures
- Not as robust as shared session storage
If you need multi-instance support, please open an issue or submit a PR implementing a shared session backend.
Security Checklist
When deploying sshler in production:
- ✅ Use HTTPS with a valid certificate (Let's Encrypt recommended)
- ✅ Set
SSHLER_COOKIE_SECURE=truein your.envfile - ✅ Set
SSHLER_PUBLIC_URLto your actual HTTPS URL - ✅ Use strong passwords (generate with
sshler hash-password) - ✅ Keep
SSHLER_REQUIRE_AUTH=true(never disable auth in production) - ✅ Bind to localhost (
SSHLER_HOST=127.0.0.1) when behind a reverse proxy - ✅ Enable firewall rules to restrict access to trusted networks
- ✅ Keep sshler updated to receive security patches
Network Security Layers
sshler security works in layers:
- Transport Security (HTTPS) - Encrypts all traffic, protects session cookies
- Application Auth (Session Cookies) - Verifies user identity with httpOnly cookies
- CSRF Protection - Origin header validation on state-changing requests
- Network Isolation (Optional) - Tailscale, VPN, or firewall rules
Recommendation: Use HTTPS + session auth for most deployments. Add network isolation (Tailscale/VPN) for extra security when accessing over the internet.
Why Cookie Sessions Instead of JWTs?
TL;DR: JWTs solve distributed stateless auth. We don't have that problem. Cookie sessions are simpler, more secure, and revocable.
Decision rationale:
-
Immediate Revocation
- Sessions can be invalidated server-side instantly (logout, security breach, admin action)
- JWTs cannot be revoked without complex deny-lists (which defeats "stateless")
- Critical for admin tools where you need emergency access control
-
Simpler Security Model
- No key rotation complexity
- No JWT claims validation edge cases
- No "where do we store the JWT" bikeshedding (localStorage = XSS vulnerable, cookies = use sessions instead)
-
Correct Use Case
- JWTs are for: Service-to-service auth, distributed microservices, mobile apps without cookie support
- Sessions are for: Browser-based apps talking to a single backend (sshler's architecture)
-
Security Benefits
- httpOnly cookies prevent XSS token theft (JavaScript can't access them)
- SameSite=Lax prevents CSRF attacks
- Shorter attack window (8-hour default TTL vs typical JWT refresh token patterns)
When to use JWTs:
- Microservices passing tokens between services
- Mobile apps that can't use cookies reliably
- Truly stateless APIs serving thousands of independent clients
- Cross-domain authentication (e.g., SSO provider)
When to use sessions (our case):
- Browser-based admin tools
- Single backend (or shared session store)
- Need immediate revocation
- Same-origin or tightly controlled CORS deployment
Bottom line: We chose the boring, correct solution for browser authentication. If you need JWTs, you need a different architecture first (distributed services, mobile clients, etc.). For a browser-based SSH manager, cookie sessions are the right tool.
Autostart
Windows (Task Scheduler)
- Run
where sshlerto locate the installed executable (for example,%LOCALAPPDATA%\Programs\Python\Python312\Scripts\sshler.exe). - Open Task Scheduler → Create Task….
- Under Triggers, add "At log on".
- Under Actions, choose "Start a program" and point to the
sshler.exepath. Add arguments such asserve --no-browserand set Start in to a writable directory. - Tick "Run with highest privileges" if you need WSL access, then save. sshler will now launch automatically every time you sign in.
Linux / macOS (systemd user service)
Create ~/.config/systemd/user/sshler.service:
[Unit]
Description=sshler – local tmux bridge
After=network.target
[Service]
Type=simple
ExecStart=%h/.local/bin/sshler serve --bind 127.0.0.1 --no-browser
Restart=on-failure
KillMode=process
[Install]
WantedBy=default.target
Important:
KillMode=processprevents systemd from killing tmux sessions when restarting the service.
Reload and enable:
systemctl --user daemon-reload
systemctl --user enable --now sshler.service
Claude Code skills
If you use Claude Code to develop sshler, this repo ships several skill docs that teach Claude the non-obvious parts of the codebase:
docs/skills/markdown-preview/SKILL.md— what renders, what doesn't, and quirks of the print-to-PDF pipeline.docs/skills/progress-bars/SKILL.md— push protocol, WebSocket fan-out, per-box subscription model.docs/skills/diff-notebook/SKILL.md— the multi-cell diff workspace: command parser, base64 URL state, server-side persistence, the "no new backend" architecture decision.
Claude won't auto-discover these — they live in the repo but aren't registered with the Claude Code runtime. A common pattern is to symlink them into your global skills directory (typically ~/.claude/skills/) so any Claude Code session on your machine can load them by name:
# One-time setup per machine
for skill in markdown-preview progress-bars diff-notebook; do
mkdir -p ~/.claude/skills/sshler-$skill
ln -sf "$PWD/docs/skills/$skill/SKILL.md" ~/.claude/skills/sshler-$skill/SKILL.md
done
After that, Claude sessions see skills named sshler, sshler-progress-bars, and sshler-diff-notebook. Restart Claude Code once (skills load at startup) and they show up alongside the built-in ones. This is the same idea as plugins/marketplaces — just hand-rolled and machine-local. The docs themselves stay versioned in the repo, so the source of truth is always git; the symlinks are just registration.
Dependencies & licenses
- FastAPI, uvicorn, asyncssh, platformdirs, pyyaml, pydantic (PyPI packages, permissive licenses)
- Vue 3 + Pinia (MIT) powers the frontend SPA
- xterm.js (MIT) provides the browser terminal
- CodeMirror (MIT) powers the file editor
All assets are used under their respective MIT/BSD-style licenses. sshler itself ships under the MIT license.
Why "sshler"?
Because sometimes you want less VS Code, more terminal — but still in a nice browser tab.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sshler-0.12.0.tar.gz.
File metadata
- Download URL: sshler-0.12.0.tar.gz
- Upload date:
- Size: 4.1 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7a8b96c92aa5962e92aedd7718555e6167a7e555f20b6229034a3a10925f8489
|
|
| MD5 |
96c049a34372b8a480f7241095aa8ec6
|
|
| BLAKE2b-256 |
84fffab6745e006b3c83dce0e90a89f3008cc513968889a5a7f93673880f6478
|
Provenance
The following attestation bundles were made for sshler-0.12.0.tar.gz:
Publisher:
pypi.yml on gabu-quest/sshler
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sshler-0.12.0.tar.gz -
Subject digest:
7a8b96c92aa5962e92aedd7718555e6167a7e555f20b6229034a3a10925f8489 - Sigstore transparency entry: 1652483780
- Sigstore integration time:
-
Permalink:
gabu-quest/sshler@bc0e601b5bad3bce535043d9a1467268c29c044d -
Branch / Tag:
refs/tags/v0.12.0 - Owner: https://github.com/gabu-quest
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
pypi.yml@bc0e601b5bad3bce535043d9a1467268c29c044d -
Trigger Event:
push
-
Statement type:
File details
Details for the file sshler-0.12.0-py3-none-any.whl.
File metadata
- Download URL: sshler-0.12.0-py3-none-any.whl
- Upload date:
- Size: 4.1 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
01a2c8d1f439f4432d0d44a81cac2430855e59b35178f9083322894a53bc7afb
|
|
| MD5 |
1d8bddbedad8fc7497bc0c4b6578f4f1
|
|
| BLAKE2b-256 |
9a6a86c953e662cf44b63d8d1e833b54db5ece9c4ef855bfb660045a0cb33017
|
Provenance
The following attestation bundles were made for sshler-0.12.0-py3-none-any.whl:
Publisher:
pypi.yml on gabu-quest/sshler
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sshler-0.12.0-py3-none-any.whl -
Subject digest:
01a2c8d1f439f4432d0d44a81cac2430855e59b35178f9083322894a53bc7afb - Sigstore transparency entry: 1652483885
- Sigstore integration time:
-
Permalink:
gabu-quest/sshler@bc0e601b5bad3bce535043d9a1467268c29c044d -
Branch / Tag:
refs/tags/v0.12.0 - Owner: https://github.com/gabu-quest
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
pypi.yml@bc0e601b5bad3bce535043d9a1467268c29c044d -
Trigger Event:
push
-
Statement type: