Sign inSign up

kalexnolasco/webgate

By kalexnolasco

โ€ขUpdated 28 days ago

Self-hosted SSH terminal + SFTP file browser for remote server management

Image
0

4.4K

kalexnolasco/webgate repository overview

โ webgate

PyPI Python License FastAPI Docker Tests Docs

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โ 


โ Quick start

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.

โ Upgrading
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 --build

Full walkthrough with screenshots: docs/LOCAL_TESTING.mdโ .


โ Why webgate?

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.

โ The problem
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
โ The solution

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
โ Use cases
ScenarioHow webgate helps
Restricted client networksOnly the gateway is HTTP-reachable; webgate proxies SSH/SFTP from there
On-call / incident responseOpen a browser anywhere, no laptop with keys needed; share the live session for pair-debugging
Team onboardingAdmin creates a user, assigns groups; new engineer has access in seconds
Audit & complianceCentralized access point, an audit log that names every file touched and every access change, optional asciinema session recording
Multi-client / agencyOne webgate per client, isolated server registries; run lots of them cheaply

โ Features

CategoryCapabilities
Terminalxterm.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)
SFTPFull 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 RegistryGroups, tags, password/key auth, encrypted at rest (Fernet), verified host keys (TOFU), favourites and recents, import/export JSON, jump host / bastion chaining
Access ControlAdmin/user roles, per-server SSH/SFTP toggles, SFTP path restrictions, read-only SFTP mode, group-based visibility
AuthSingle 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
ComplianceSession 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
MigrationFull-state backup and restore โ€” servers with credentials, users, groups, webhooks and API keys in one passphrase-encrypted file, portable between instances
AI agentPer-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
BrandingWhite-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
MonitoringBackground SSH connectivity probes, online/offline indicator, webhook alerts when a server goes down or comes back, Prometheus /metrics
DeploymentMulti-stage Docker image, SQLite default or PostgreSQL, runs behind any reverse proxy at any sub-path, demo mode for public read-only deployments
UXCommand palette (Ctrl+P), dark/light theme, responsive, vanilla JS + Alpine.js (no npm needed), session persistence across reloads
โ Keyboard
ShortcutAction
Ctrl+P / Ctrl+Shift+PCommand palette โ€” fuzzy jump to any server or action
Ctrl+Shift+FSearch the terminal scrollback
Ctrl+KQuick Connect
Ctrl+1Site Manager
Ctrl+NNew server (admin)
EscClose 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.


โ Screenshots

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.

โ Site Manager, terminal, SFTP, command palette
Site ManagerSSH terminal
SFTP browserCommand palette

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.

โ Admin
SettingsAI agent
BrandingUsers

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.

โ Light theme, and the sign-in screen
Light themeSign in

โ Architecture

โ Project layout
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)
โ What talks to what

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โ ).

โ Four constraints that decided the rest
  1. The gateway is the credential store. It is why host keys are verified before authentication, why the Fernet key stays in the environment, and why the agent's tools are read-only.
  2. Workers are interchangeable. Nothing durable lives in memory or on a worker's disk. What stays local โ€” pooled connections, open PTYs โ€” is reconstructible and expected to be lost.
  3. Migrations are additive, append-only, idempotent. No column is ever dropped or retyped, so an older release ignores what it does not know and a rollback is safe. A test enforces it.
  4. The frontend has no build step. One HTML file. There is no compiled asset that can drift from the source it came from.
โ Jump host (bastion) chaining

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
โ Shared terminal session

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
โ Access model โ€” groups, tags, LDAP

Three concepts, easy to mix up. Here's how they fit together:

ConceptTypeDefined byWhat it does
Server.groupsingle string per server (e.g. production)admin, in the Add Server formgates visibility: a non-admin user only sees servers whose group is in their allowed_groups
Server.tagslist of strings (e.g. ["nginx","eu-west-1"])admin, in the Add Server formcosmetic / search only โ€” does not affect access
User.allowed_groupslist of stringsadmin (Users panel) or LDAP mappingthe set of Server.group values a non-admin user is allowed to see
User.is_adminbooladmin (Users panel) or an LDAP admin groupadmins 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 &rarr; Settings &rarr; LDAP"]
        M["{<br/>  &quot;devs&quot;: &quot;production&quot;,<br/>  &quot;sre&quot;: &quot;all&quot;<br/>}"]
    end
    subgraph User
        U["alice.allowed_groups<br/>= [&quot;production&quot;]"]
    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:

  • LDAP does not create groups on the webgate side. The right-hand value of the group mapping must match exactly what you typed in Server.group.
  • An LDAP group that isn't in the map is silently ignored.
  • Admin groups is independent of the map: any membership in those groups grants admin (and admins see all servers).
  • Tags are never used for access control, only for filtering / search in the UI.
โ LDAP authentication

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

โ Configuration

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.

โ In the admin panel
SettingDefaultWhat it does
Verify SSH host keystruePin 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 lifetime1440Minutes a sign-in stays valid
Idle SSH timeout3600Seconds a session may sit with no input or output before it is closed. 0 disables it
Maximum transfer size104857600Bytes, for uploads and downloads alike. 0 removes the limit, and with it the protection against one large file exhausting the gateway
Disable status checksfalseStop probing servers for their online/offline dot
Check interval / timeout / parallel checks60 / 5 / 10How the status monitor sweeps the registry
Allow session recordingfalseLets servers record SSH sessions to asciinema cast files. Each server opts in separately
Maximum recording size26214400Bytes per session. A recording that reaches it stops and says so inside the replay
LDAP (10 settings)offDirectory 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.

โ Environment only
VariableDefaultDescriptionWhy not in the panel
WEBGATE_SECRET_KEYnone โ€” you must set itSigns session tokens and derives the Fernet key for stored credentials. webgate refuses to start with the shipped default on any address other than loopbackIt decrypts what is in the database, so it cannot live there
WEBGATE_ALLOW_INSECURE_SECRETfalseStart anyway with the default key. Only sensible where nothing else can reach the machineIt is the override itself
WEBGATE_DB_URLsqlite+aiosqlite:///./webgate.dbSQLAlchemy async URL. Use postgresql+asyncpg://user:pass@host:5432/webgate for PostgresIt is how the database is reached
WEBGATE_HOST0.0.0.0Bind addressRead before the app can serve a request
WEBGATE_PORT8443Bind portRead 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 unchangedRoutes are mounted at startup
WEBGATE_LOG_LEVELinfouvicorn log levelRead once by the server process
WEBGATE_DEMO_MODEfalseRead-only public demo: blocks writes, hides admin UI, seeds demo/demo, shows a bannerEnabling it from the panel would block turning it off
WEBGATE_JWT_ALGORITHMHS256JWT algorithmOne typo away from accepting unsigned tokens
WEBGATE_FIRST_RUNtrueCreate the default admin/admin account when no users exist. Set false when accounts come from LDAP or a restored backupDecided before anyone can sign in
WEBGATE_CONFIG_LOCKEDfalseMake the settings panel read-onlyIt is the lock itself
WEBGATE_ALLOWED_ORIGINS*CORS origins (comma-separated)Middleware is built at startup
WEBGATE_RECORDINGS_DIR./recordingsScratch space while a session is live; the finished cast is stored in the database so any worker can serve itA filesystem path, not a preference
WEBGATE_INSTANCE_IDautoUnique per worker; a UUID is generated when emptyIdentifies the process

Every panel setting also accepts its WEBGATE_-prefixed variable as the initial value: WEBGATE_MONITOR_INTERVAL, WEBGATE_LDAP_URL, and so on.

โ LDAP / Active Directory

The same fields, for seeding them from the environment:

VariableDefaultDescription
WEBGATE_LDAP_ENABLEDfalseEnable 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)

Tag summary

Content type

Image

Digest

sha256:25633199eโ€ฆ

Size

74 MB

Last updated

28 days ago

docker pull kalexnolasco/webgate