Self-hosted SSH terminal + SFTP file browser for remote server management
4.4K
Self-hosted web app for remote server management โ SSH terminal, SFTP file browser, server registry, all in your browser. Host keys are verified, credentials are encrypted at rest, and an admin configures the whole thing from inside the app. A modern Python replacement that combines the best of websshโ and filebrowserโ into a single tool. It borrows FileZilla's workflow โ a site list, quick connect, a path bar โ without copying its chrome: a dense, keyboard-first console meant to sit behind a terminal for hours.
๐ฎ Try it live: webgate-demo.fly.devโ โ login
demo/demo(read-only sandbox, resets hourly)๐ Docs: kalexnolasco.github.io/webgateโ
export WEBGATE_SECRET_KEY=$(openssl rand -hex 32)
docker compose up -d
# open http://localhost:8443/ โ login: admin / admin
That's it. The first login forces a password change. Add servers from the Site Manager, click SSH or SFTP to connect.
For a richer dev environment with a sandboxed SSH target pre-baked: docker compose -f compose.dev.yml up --build.
docker compose pull && docker compose up -d
The schema migrates itself on boot โ no migration command, no tool to learn. Changes are additive only, so rolling back to an older image works if you need it. Back up first (cp webgate.db webgate.db.bak, or Admin โ Backup & restore). Full guide: Upgradingโ .
On v0.x? That line is legacy and unsupported โ see Legacy versionsโ before upgrading.
๐งช Want to try every feature end-to-end? A ready-to-run playground brings up webgate plus an LDAP server, a public SSH host, a private SSH host only reachable via a bastion, and an HTTP echo for webhooks:
docker compose -f compose.playground.yml up -d --buildFull walkthrough with screenshots: docs/LOCAL_TESTING.mdโ .
Managing remote servers means juggling SSH clients, SFTP tools, credentials and VPN configs across your team. In many real-world setups direct SSH access to every server isn't possible โ only HTTP(S) reaches the gateway.
flowchart TB
subgraph internet ["Internet"]
YOU["Your Team"]
end
subgraph firewall ["Client Firewall"]
GW["Gateway Server<br/>(HTTP only)"]
subgraph internal ["Internal Network"]
DB1[(PostgreSQL<br/>10.0.1.10)]
DB2[(MySQL<br/>10.0.1.11)]
APP1["App Server<br/>10.0.1.20"]
APP2["App Server<br/>10.0.1.21"]
WORKER["Worker<br/>10.0.1.30"]
REDIS["Redis<br/>10.0.1.40"]
end
end
YOU -- "HTTPS :443" --> GW
GW -. "SSH :22" .-> DB1
GW -. "SSH :22" .-> DB2
GW -. "SSH :22" .-> APP1
GW -. "SSH :22" .-> APP2
GW -. "SSH :22" .-> WORKER
GW -. "SSH :22" .-> REDIS
style internet fill:none,stroke:#4a90d9,stroke-width:2px
style firewall fill:none,stroke:#ff9800,stroke-width:2px
style internal fill:none,stroke:#5cb85c,stroke-width:2px
style GW fill:#ffcc02,stroke:#e6a800,color:#333
style YOU fill:#4a90d9,stroke:#2a6cb5,color:#fff
Deploy webgate on the gateway. Everyone gets browser-based SSH and SFTP to every internal server โ no VPN, no scattered SSH keys, full audit trail.
flowchart TB
subgraph internet ["Internet"]
ENG1["Engineer 1<br/>(Browser)"]
ENG2["Engineer 2<br/>(Browser)"]
ENG3["Engineer 3<br/>(Browser)"]
end
subgraph firewall ["Client Firewall"]
WG["webgate<br/>Gateway Server :443"]
subgraph internal ["Internal Network"]
DB1[(PostgreSQL)]
APP1["App Server"]
WORKER["Worker"]
REDIS["Redis"]
end
end
ENG1 -- "HTTPS" --> WG
ENG2 -- "HTTPS" --> WG
ENG3 -- "HTTPS" --> WG
WG -- "SSH/SFTP" --> DB1
WG -- "SSH/SFTP" --> APP1
WG -- "SSH/SFTP" --> WORKER
WG -- "SSH/SFTP" --> REDIS
style internet fill:none,stroke:#4a90d9,stroke-width:2px
style firewall fill:none,stroke:#ff9800,stroke-width:2px
style internal fill:none,stroke:#5cb85c,stroke-width:2px
style WG fill:#5cb85c,stroke:#449d44,color:#fff
style ENG1 fill:#4a90d9,stroke:#2a6cb5,color:#fff
style ENG2 fill:#4a90d9,stroke:#2a6cb5,color:#fff
style ENG3 fill:#4a90d9,stroke:#2a6cb5,color:#fff
| Scenario | How webgate helps |
|---|---|
| Restricted client networks | Only the gateway is HTTP-reachable; webgate proxies SSH/SFTP from there |
| On-call / incident response | Open a browser anywhere, no laptop with keys needed; share the live session for pair-debugging |
| Team onboarding | Admin creates a user, assigns groups; new engineer has access in seconds |
| Audit & compliance | Centralized access point, an audit log that names every file touched and every access change, optional asciinema session recording |
| Multi-client / agency | One webgate per client, isolated server registries; run lots of them cheaply |
| Category | Capabilities |
|---|---|
| Terminal | xterm.js + asyncssh, multi-tab, resize, copy/paste, auto-reconnect with backoff on a dropped link, shared sessions with one-click URL, command snippets โ team-shared, with {parameters} and confirm-before-running, scrollback search (Ctrl+Shift+F) |
| SFTP | Full file ops + drag & drop upload, sortable columns, multi-select with batch ZIP download and delete, hidden-file toggle, in-browser editor (CodeMirror 6) with syntax highlighting for ~90 languages, PDF/image preview, server-to-server copy that never touches your machine |
| Server Registry | Groups, tags, password/key auth, encrypted at rest (Fernet), verified host keys (TOFU), favourites and recents, import/export JSON, jump host / bastion chaining |
| Access Control | Admin/user roles, per-server SSH/SFTP toggles, SFTP path restrictions, read-only SFTP mode, group-based visibility |
| Auth | Single sign-on (OpenID Connect) โ Entra ID, Okta, Google Workspace, Keycloak โ with groupโrole mapping, plus JWT + bcrypt locally, 2FA TOTP, API keys for automation, LDAP / Active Directory |
| Compliance | Session recording to asciinema cast files with browser replay, off by default and opted into per server, audit log covering every SFTP operation, registry and account change, searchable by filename, webhooks (HMAC-signed) on key events |
| Migration | Full-state backup and restore โ servers with credentials, users, groups, webhooks and API keys in one passphrase-encrypted file, portable between instances |
| AI agent | Per-server iterative chat over Ollama or OpenRouter, read-only inspection tools, works on SFTP-only hosts, cached results and searchable findings โ configured in the admin panel, off until then |
| Branding | White-label the deployment: app name, logo, sign-in image, browser icon, company colours with a palette picker and live preview, light and dark palettes โ applied for every user |
| Monitoring | Background SSH connectivity probes, online/offline indicator, webhook alerts when a server goes down or comes back, Prometheus /metrics |
| Deployment | Multi-stage Docker image, SQLite default or PostgreSQL, runs behind any reverse proxy at any sub-path, demo mode for public read-only deployments |
| UX | Command palette (Ctrl+P), dark/light theme, responsive, vanilla JS + Alpine.js (no npm needed), session persistence across reloads |
| Shortcut | Action |
|---|---|
Ctrl+P / Ctrl+Shift+P | Command palette โ fuzzy jump to any server or action |
Ctrl+Shift+F | Search the terminal scrollback |
Ctrl+K | Quick Connect |
Ctrl+1 | Site Manager |
Ctrl+N | New server (admin) |
Esc | Close the palette, a dialog, or the quick-connect bar |
Ctrl+Shift+P reaches the palette from inside a terminal too; plain Ctrl+P is deliberately left alone there so readline keeps previous-command.
Taken by the browser tests (tests/e2e) against a real webgate and a real SSH host, so
they show the build those tests just drove and cannot drift into an interface that no
longer exists.
![]() | ![]() |
![]() | ![]() |
Left to right: the registry with live status dots and grouped servers; an SSH session
opened through bastion-eu; the file browser with sortable columns, owner names and
multi-select; Ctrl+P over every server and action.
![]() | ![]() |
![]() | ![]() |
Settings shows where each value comes from โ this panel, a named environment variable, or the shipped default โ and warns in red where a choice loosens something. AI agent is off until a provider is configured. Branding white-labels the deployment for every user.
![]() | ![]() |
src/webgate/
โโโ __main__.py uvicorn launcher
โโโ app.py FastAPI factory, lifespan, middleware
โโโ config.py boot settings (env only); the rest live in the admin panel
โโโ agent/ per-server AI chat: provider, tools, SFTP-only tools, cache
โโโ audit/ immutable action log
โโโ auth/ JWT + bcrypt, 2FA TOTP, API keys, LDAP, user management
โโโ backup/ full-state export/import, passphrase-encrypted
โโโ branding/ white-label store: name, logo, colours, favicon
โโโ db/ async engine + additive, append-only migrations
โโโ files/
โ โโโ sftp_service.py SFTP operations, chunked reads, ZIP builders
โ โโโ pool.py connection reuse, 300 s TTL
โ โโโ limits.py per-request transfer budget
โ โโโ routes.py REST endpoints
โโโ recordings/ asciinema cast v2 writer + browser replay
โโโ runtime_config/
โ โโโ registry.py every setting an admin may change, with its validation
โ โโโ store.py key/value rows, encrypted secrets, in-process snapshot
โ โโโ routes.py /api/settings
โโโ servers/
โ โโโ hostkeys.py trust-on-first-use pinning and verification
โ โโโ monitor.py leader-elected status sweep
โ โโโ crypto.py Fernet credential encryption
โ โโโ service.py registry CRUD, jump-host resolution
โโโ snippets/ per-user command library
โโโ terminal/
โ โโโ ssh_session.py asyncssh wrapper, optional jump tunnel
โ โโโ shared.py SharedSession registry: 1 PTY โ N WebSockets, idle watchdog
โ โโโ ws_handler.py input multiplex / output broadcast
โ โโโ routes.py WS endpoints + share-token mint/revoke
โโโ webhooks/ HMAC-signed event dispatcher
โโโ static/index.html single-file frontend (Alpine.js + xterm.js + CodeMirror)
Browsers speak HTTPS and WebSocket to one address. Everything behind it โ SSH, SFTP, LDAP, the model provider โ is reached from the gateway, outbound. That is the point: in most deployments the servers have no route to the internet and the people have no route to the servers.
flowchart LR
subgraph people["People"]
B["Browser<br/>Alpine.js + xterm.js"]
end
subgraph gw["Gateway โ the only host with both routes"]
W["webgate<br/>FastAPI :8443"]
DB[("SQLite or PostgreSQL<br/>credentials encrypted<br/>with Fernet")]
end
subgraph fleet["Internal network โ no inbound internet"]
BAS["Bastion"]
S1["prod-web-01 :22"]
S2["prod-db-primary :22"]
S3["SFTP-only host"]
end
subgraph ext["Outbound, optional"]
LD["LDAP / AD"]
AI["Ollama or OpenRouter"]
WH["Webhook receivers"]
end
B -->|"HTTPS + WebSocket"| W
W --- DB
W -->|"SSH / SFTP"| BAS
BAS -.->|"tunnel"| S1
BAS -.->|"tunnel"| S2
W -->|"SFTP only"| S3
W -->|"bind + search"| LD
W -->|"chat completions"| AI
W -->|"HMAC-signed POST"| WH
Only the gateway needs a route to the model provider โ inspected hosts never do, because the agent reaches them over SSH from here. Nothing is installed on the targets: a server only has to accept SSH, and some only accept SFTP.
๐ Full architecture, with five diagrams โ module map, the SSH session including the part that refuses, multi-instance topology, and how a setting resolves: docs/architecture.mdโ (renderedโ ).
When a server has jump_via_id set, webgate opens the SSH connection to the bastion first and tunnels the target connection through it. Same chain is used for the SFTP browser. No VPN required, only outbound SSH from the gateway to the bastion.
flowchart LR
B["Browser"]
WG["webgate"]
BAST["bastion<br/>10.0.0.1"]
INT["internal-app<br/>10.0.1.50"]
B -- "HTTPS / WSS" --> WG
WG -- "SSH" --> BAST
BAST -- "SSH (tunneled)" --> INT
style B fill:#4a90d9,stroke:#2a6cb5,color:#fff
style WG fill:#5cb85c,stroke:#449d44,color:#fff
style BAST fill:#ffcc02,stroke:#e6a800,color:#333
style INT fill:#b25e00,stroke:#ff9800,color:#fff
The owner's terminal is registered with a SharedSession. When the owner clicks Share, a token is minted and any joiner with the URL attaches a second WebSocket. There's still one SSH PTY โ output is broadcast to all clients, input from any RW client is multiplexed into the same stdin.
flowchart LR
O["Owner WS"]
J1["Joiner WS (rw)"]
J2["Joiner WS (ro)"]
SS["SharedSession"]
PTY["asyncssh PTY"]
REMOTE["Remote SSH server"]
REC["CastRecorder<br/>(if recording on)"]
O -- "input" --> SS
J1 -- "input" --> SS
J2 -. "no input" .-> SS
SS -- "write stdin" --> PTY
PTY -- "stdout" --> SS
SS -- "broadcast" --> O
SS -- "broadcast" --> J1
SS -- "broadcast" --> J2
SS -. "tee" .-> REC
PTY <--> REMOTE
style SS fill:#5cb85c,stroke:#449d44,color:#fff
style PTY fill:#ffcc02,stroke:#e6a800,color:#333
style REC fill:#a78bfa,stroke:#7c3aed,color:#fff
Three concepts, easy to mix up. Here's how they fit together:
| Concept | Type | Defined by | What it does |
|---|---|---|---|
Server.group | single string per server (e.g. production) | admin, in the Add Server form | gates visibility: a non-admin user only sees servers whose group is in their allowed_groups |
Server.tags | list of strings (e.g. ["nginx","eu-west-1"]) | admin, in the Add Server form | cosmetic / search only โ does not affect access |
User.allowed_groups | list of strings | admin (Users panel) or LDAP mapping | the set of Server.group values a non-admin user is allowed to see |
User.is_admin | bool | admin (Users panel) or an LDAP admin group | admins see everything regardless of allowed_groups |
With LDAP, the admin still controls which group names exist by typing them when registering each server. LDAP only populates the user side of the equation:
flowchart LR
subgraph LDAP
L1["alice โ cn=devs"]
L2["alice โ cn=admins"]
end
subgraph MAP["Group mapping<br/>Admin → Settings → LDAP"]
M["{<br/> "devs": "production",<br/> "sre": "all"<br/>}"]
end
subgraph User
U["alice.allowed_groups<br/>= ["production"]"]
end
subgraph Servers
S1["app-1<br/>group=production โ
"]
S2["app-2<br/>group=staging โ"]
S3["db-1<br/>group=production โ
"]
end
L1 -- mapped --> M
L2 -. ignored<br/>(not in map) .-> M
M --> U
U --> S1
U --> S3
Key rules:
Server.group.Search-then-bind: webgate binds as the service account, finds the user DN, re-binds as the user with their password to verify credentials, then enumerates LDAP groups and maps them to webgate groups (and admin status).
sequenceDiagram
participant Browser
participant webgate
participant LDAP
Browser->>webgate: POST /api/auth/login (alice, ****)
webgate->>webgate: try local password (miss)
webgate->>LDAP: bind(svc-DN, svc-password)
LDAP-->>webgate: ok
webgate->>LDAP: search(uid=alice) under user_base
LDAP-->>webgate: dn=uid=alice,ou=people,...
webgate->>LDAP: re-bind(user-DN, user-password)
LDAP-->>webgate: ok โ
webgate->>LDAP: search(member=user-DN) under group_base
LDAP-->>webgate: [devs, admins]
webgate->>webgate: map โ allowed_groups, is_admin
webgate->>webgate: upsert local User row
webgate-->>Browser: JWT
Most settings live in Admin โ Settings, in the app. They take effect without a restart and apply to every instance of the gateway, so changing a monitor interval or pointing at a different LDAP server is not a redeploy.
The rest are environment variables prefixed with WEBGATE_, because they are read
before webgate can serve the request that would change them โ or because letting the
panel change them would be a way to lock yourself out.
| Setting | Default | What it does |
|---|---|---|
| Verify SSH host keys | true | Pin each server's key on first connection and refuse a changed one. Off means the gateway hands stored credentials to whatever answers โ only sensible in a throwaway lab |
| Session token lifetime | 1440 | Minutes a sign-in stays valid |
| Idle SSH timeout | 3600 | Seconds a session may sit with no input or output before it is closed. 0 disables it |
| Maximum transfer size | 104857600 | Bytes, for uploads and downloads alike. 0 removes the limit, and with it the protection against one large file exhausting the gateway |
| Disable status checks | false | Stop probing servers for their online/offline dot |
| Check interval / timeout / parallel checks | 60 / 5 / 10 | How the status monitor sweeps the registry |
| Allow session recording | false | Lets servers record SSH sessions to asciinema cast files. Each server opts in separately |
| Maximum recording size | 26214400 | Bytes per session. A recording that reaches it stops and says so inside the replay |
| LDAP (10 settings) | off | Directory URL, bind account, search bases and filters, group mapping, admin groups |
Each one shows where its current value comes from โ this panel, an environment variable, or the shipped default โ and can be reset back to the environment.
An environment variable still seeds a fresh install, which is what makes automated
provisioning work; once a value is set in the panel, the panel wins. To keep
configuration entirely in your manifests, set WEBGATE_CONFIG_LOCKED=true and the
panel becomes read-only.
| Variable | Default | Description | Why not in the panel |
|---|---|---|---|
WEBGATE_SECRET_KEY | none โ you must set it | Signs session tokens and derives the Fernet key for stored credentials. webgate refuses to start with the shipped default on any address other than loopback | It decrypts what is in the database, so it cannot live there |
WEBGATE_ALLOW_INSECURE_SECRET | false | Start anyway with the default key. Only sensible where nothing else can reach the machine | It is the override itself |
WEBGATE_DB_URL | sqlite+aiosqlite:///./webgate.db | SQLAlchemy async URL. Use postgresql+asyncpg://user:pass@host:5432/webgate for Postgres | It is how the database is reached |
WEBGATE_HOST | 0.0.0.0 | Bind address | Read before the app can serve a request |
WEBGATE_PORT | 8443 | Bind port | Read before the app can serve a request |
WEBGATE_ROOT_PATH | `` (empty) | URL prefix behind a reverse proxy (e.g. /webgate); the proxy must forward it unchanged | Routes are mounted at startup |
WEBGATE_LOG_LEVEL | info | uvicorn log level | Read once by the server process |
WEBGATE_DEMO_MODE | false | Read-only public demo: blocks writes, hides admin UI, seeds demo/demo, shows a banner | Enabling it from the panel would block turning it off |
WEBGATE_JWT_ALGORITHM | HS256 | JWT algorithm | One typo away from accepting unsigned tokens |
WEBGATE_FIRST_RUN | true | Create the default admin/admin account when no users exist. Set false when accounts come from LDAP or a restored backup | Decided before anyone can sign in |
WEBGATE_CONFIG_LOCKED | false | Make the settings panel read-only | It is the lock itself |
WEBGATE_ALLOWED_ORIGINS | * | CORS origins (comma-separated) | Middleware is built at startup |
WEBGATE_RECORDINGS_DIR | ./recordings | Scratch space while a session is live; the finished cast is stored in the database so any worker can serve it | A filesystem path, not a preference |
WEBGATE_INSTANCE_ID | auto | Unique per worker; a UUID is generated when empty | Identifies the process |
Every panel setting also accepts its WEBGATE_-prefixed variable as the initial
value: WEBGATE_MONITOR_INTERVAL, WEBGATE_LDAP_URL, and so on.
The same fields, for seeding them from the environment:
| Variable | Default | Description |
|---|---|---|
WEBGATE_LDAP_ENABLED | false | Enable LDAP fallback after local credential check |
WEBGATE_LDAP_URL | `` | ldap://host:389 or ldaps://host:636 |
WEBGATE_LDAP_BIND_DN | `` | Service account DN, e.g. cn=admin,dc=example,dc=com |
WEBGATE_LDAP_BIND_PASSWORD | `` | Service account password |
WEBGATE_LDAP_USER_BASE | `` | e.g. ou=people,dc=example,dc=com |
WEBGATE_LDAP_USER_FILTER | (uid={username}) | AD: (sAMAccountName={username}) |
WEBGATE_LDAP_GROUP_BASE | `` | e.g. ou=groups,dc=example,dc=com (empty = no group lookup) |
Content type
Image
Digest
sha256:25633199eโฆ
Size
74 MB
Last updated
28 days ago
docker pull kalexnolasco/webgate