Skip to main content

A local FastAPI-powered SSH multiplexer for tmux-in-browser — from your laptop only.

Project description

sshler

日本語

PyPI version Python versions CI License: MIT

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 -G when 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. Press c to focus from anywhere, ? for help, j/k to 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.md for 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 .md file via the eyeball button
  • Mermaid Diagrams - Flowcharts, sequence diagrams, state diagrams, timelines, class diagrams etc. render inline from ```mermaid fenced blocks. Mermaid is lazy-loaded — no bundle cost unless you actually view a markdown file with diagrams.
  • Embedded Images - ![alt](./diagram.png) and ![alt](../assets/foo.jpg) resolve against the file's directory and are fetched via the preview API, then rewritten to data: 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 &lt; / &ge; / &amp; 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: avoid on 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: exact is 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 deploy while you're on the sshler box, switch to maintenance, 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/progress management 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/progress on 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-motion system 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_dir if your home path isn't /home/<user>. If you rely on an OpenSSH alias, add ssh_alias: and sshler will run ssh -G to 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 .env file to version control. Use .env.example as a template. The .env file 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 set known_hosts: ignore if you fully understand the risk.
  • If you expose sshler beyond localhost, opt-in via --allow-origin and 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.1 for localhost-only). Use 0.0.0.0 to expose on all interfaces, but only on trusted networks with --auth and TLS.
  • --port sets the port number (default: 8822).
  • --allow-origin can be repeated to expand CORS; combine it with --auth if you expose the UI beyond localhost.
  • --auth user:pass enables HTTP basic authentication (recommended if binding to 0.0.0.0).
  • --max-upload-mb sets the upload size limit (default: 50 MB).
  • --no-ssh-alias disables the ssh -G fallback when DNS fails.
  • --token lets you supply your own X-SSHLER-TOKEN (otherwise a secure random value is generated).
  • --log-level feeds 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:

  1. Install Caddy:

    # Ubuntu/Debian
    sudo apt install caddy
    
    # macOS
    brew install caddy
    
  2. Create a Caddyfile:

    # /etc/caddy/Caddyfile or ~/Caddyfile
    
    sshler.company.internal {
        reverse_proxy localhost:8822
    }
    
  3. 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
    
  4. Start Caddy:

    # System service
    sudo systemctl start caddy
    
    # Or run directly
    caddy run --config /etc/caddy/Caddyfile
    
  5. 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.

  1. 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
    
  2. Enable Tailscale Serve (optional, for HTTPS):

    tailscale serve https / http://localhost:8822
    
  3. 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=true in your .env file
  • Set SSHLER_PUBLIC_URL to 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:

  1. Transport Security (HTTPS) - Encrypts all traffic, protects session cookies
  2. Application Auth (Session Cookies) - Verifies user identity with httpOnly cookies
  3. CSRF Protection - Origin header validation on state-changing requests
  4. 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:

  1. 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
  2. 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)
  3. 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)
  4. 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)

  1. Run where sshler to locate the installed executable (for example, %LOCALAPPDATA%\Programs\Python\Python312\Scripts\sshler.exe).
  2. Open Task Scheduler → Create Task….
  3. Under Triggers, add "At log on".
  4. Under Actions, choose "Start a program" and point to the sshler.exe path. Add arguments such as serve --no-browser and set Start in to a writable directory.
  5. 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=process prevents 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

sshler-0.12.0.tar.gz (4.1 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sshler-0.12.0-py3-none-any.whl (4.1 MB view details)

Uploaded Python 3

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

Hashes for sshler-0.12.0.tar.gz
Algorithm Hash digest
SHA256 7a8b96c92aa5962e92aedd7718555e6167a7e555f20b6229034a3a10925f8489
MD5 96c049a34372b8a480f7241095aa8ec6
BLAKE2b-256 84fffab6745e006b3c83dce0e90a89f3008cc513968889a5a7f93673880f6478

See more details on using hashes here.

Provenance

The following attestation bundles were made for sshler-0.12.0.tar.gz:

Publisher: pypi.yml on gabu-quest/sshler

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for sshler-0.12.0-py3-none-any.whl
Algorithm Hash digest
SHA256 01a2c8d1f439f4432d0d44a81cac2430855e59b35178f9083322894a53bc7afb
MD5 1d8bddbedad8fc7497bc0c4b6578f4f1
BLAKE2b-256 9a6a86c953e662cf44b63d8d1e833b54db5ece9c4ef855bfb660045a0cb33017

See more details on using hashes here.

Provenance

The following attestation bundles were made for sshler-0.12.0-py3-none-any.whl:

Publisher: pypi.yml on gabu-quest/sshler

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page