ShellCN

Self-hosted access gateway for SSH, files, containers, Kubernetes, databases, and remote desktops, with out-of-tree plugins and one audited UI.

Back to Projects
Go, gRPC, Vue 3, PrimeVue, Tailwind, WebSockets, SQLite, Casbin, xterm.js, noVNCView Live

Overview

ShellCN is a self-hosted infrastructure access gateway. One Go binary embeds the Vue frontend and the datastore, so a single process gives you a browser cockpit for SSH servers, file transfer endpoints, container hosts, Kubernetes clusters, Proxmox, SQL and NoSQL databases, and remote desktops.

The gateway ships 20 built-in protocols: SSH, SFTP, FTP and FTPS, SMB, WebDAV, S3, Docker, Swarm, Podman, Kubernetes, Proxmox, VNC, RDP, PostgreSQL, MySQL, MongoDB, Redis, LDAP, and server monitoring. Everything else arrives as an external plugin, and the maintained plugin repo already holds more than 40 of them, from ClickHouse and Oracle to Kafka, NATS, Vault, Consul, Elasticsearch, and vector stores like Qdrant and Milvus.

ShellCN Kubernetes cluster overview

Why I built it

Infrastructure access spreads across too many clients. A terminal for SSH, a separate SFTP app, Docker Desktop, kubectl, a database GUI, an RDP client. Each one keeps its own copy of the credentials, and none of them leaves an audit trail you own.

I wanted one process to broker all of it: credentials encrypted at rest, one permission model, one audit log, and session recording where a connection needs it. ShellCN brokers access to infrastructure. It does not host infrastructure.

The plugin contract

Every protocol is a plugin, and the contract is deliberately small. A plugin declares one versioned manifest (identity, config schema, capabilities, layout, resources, actions, streams) and exposes typed route handlers. The core owns the rest: routing, sessions, authentication, authorization, policy, secrets, audit, and rendering.

Plugin handlers stay out of the HTTP layer entirely. A handler receives a typed request context, binds the request body into a struct, and returns data or an error. Status codes, headers, cookies, and authentication state belong to the core, which wraps every route with authentication, authorization by permission and risk tier, session resolution, input validation, and audit.

Routes are the one behavior mechanism. Actions and streams in the manifest only reference a route ID plus UI affordances, so permission and risk stay single-sourced on the route. Adding a protocol is one Go package and one line in the plugin registry.

Rendering from a manifest

The frontend renders from a projection of the manifest, never from plugin code. It fetches the projection, builds the workspace from the declared layout and tree, and mounts panels from a fixed set of core components: terminal, table, file browser, metrics, log stream, query editor, remote desktop, object detail, canvas, and about a dozen more.

A panel binds to a data source holding a route ID and params, never a URL. Params interpolate the selected resource and the current record, with one rule for missing values: a param whose value is a single token drops out, an embedded token fails loudly. The projection excludes handler functions, mount paths, permission keys, and audit event names, so the browser renders without holding anything the core enforces.

The live runtime sits in Pinia stores rather than components. Sessions, stream channels, dock panels, scope selectors, and workspace state are keyed by connection ID, so rearranging panes or switching tabs never drops a running terminal, and two connections with overlapping resource kinds never restore each other's state. The active view syncs into the URL as a query, so Back and Forward walk visited resources and a pasted link restores the workspace without remounting it. Panels come from one lazy registry keyed by panel type, which keeps the first paint small whether the catalog holds ten protocols or sixty.

Two guards keep the declarative surface honest. Registration validates every route, action, and panel reference, and route IDs are plugin-owned, so one plugin never reaches another's route by spelling its ID. A separate UX lint rejects manifests where a destructive action skips confirmation, a stream kind mismatches its panel, or a table declares no column types. Golden projection tests keep the Go and TypeScript sides from drifting apart, and adding a plugin needs zero frontend changes.

External plugins run as subprocesses

Keeping the built-in set small only works when out-of-tree protocols are first-class. An external plugin is a separate Go binary built against the SDK and dropped into the gateway's plugin directory. It implements the same manifest, routes, and connect contract, and its projection is byte-identical to a built-in, so neither the core nor the frontend distinguishes them.

Each one runs as an isolated gRPC subprocess through hashicorp/go-plugin. The supervisor respawns a crashed plugin with bounded backoff and leaves the registered manifest and routes in place, swapping only the live client underneath, so a crash looks like a brief unavailability instead of a missing protocol. Updates avoid a gateway restart: start the new subprocess, validate its manifest, swap the registry entry atomically, drain the old sessions, then stop the old process.

The security boundary survives the process split, and this was the part worth getting right. A subprocess holds no ambient authority. It opens no sockets, reaches no database, and writes no audit on its own. Every privileged effect is a callback into a per-connection host service: dialing a target runs through the core egress guard and the connection's transport, HTTP clients get a proxy endpoint wired the same way, plugin storage stays scoped by the core to the plugin, user, and connection, and audit lands in the core log. A plugin author writes protocol logic, and the gateway keeps the single egress and audit point.

The marketplace and its trust model

Three repos support the model. The contrib repo holds the maintained external plugins, each a standalone Go module with its own release tags, sharing a helper module for SQL, S3-compatible storage, Elasticsearch-compatible REST, message brokers, and the generic file browser contract. A starter template generates a working plugin repo for third-party authors. The registry repo is the marketplace itself.

Registry submissions are one YAML file per plugin holding the release URLs and a sha256 per platform binary, across six OS and architecture targets. CI checks the schema, verifies the checksums, and loads the binary through the real plugin handshake before a merge. A mirror workflow then re-verifies the assets and re-releases them from the registry repo, so an upstream maintainer deleting a release breaks nothing, and mirror releases stay immutable.

Two decisions I like here. The Marketplace shows a projection snapshot CI took from the verified binary, so the permission, risk, and transport surface you review is the binary's own, not text the submitter typed. And the gateway verifies the sha256 again at install time, which makes the checksum the trust root end to end. A version marked yanked disappears from installs registry-side with no cooperation from the upstream maintainer. Operators point the gateway at several index URLs, merged by plugin name with the first hit winning, so a private index fronts the public one.

Reaching private targets

Plenty of targets sit behind NAT or a firewall, so connectivity is orthogonal to protocol. A connection declares direct transport (the gateway dials out) or agent transport (a small static agent inside the target dials back over one outbound multiplexed tunnel).

Plugins never branch on the mode. The core wires the transport at the layer each client needs: a socket dialer for protocols like SSH, Docker, and Postgres, or a full HTTP client for libraries like the Kubernetes client, where the agent runs a reverse proxy and injects the target credentials. Enrollment is connection scoped, and the install token travels as an environment variable, a handshake message, or a single-use ticket, never in a path or a query string.

Two upstream clients ignore a custom dialer and demand a real socket: the Kubernetes client when it upgrades a connection for exec or port-forward, and the Docker client when it takes over a connection for exec. Under agent transport both run over a short-lived local bridge, so the tunnel still carries them. UDP targets ride a framed mode with a length prefix per datagram, which keeps message boundaries intact through a stream tunnel.

Security, audit, and recording

Secret fields are encrypted with AES-256-GCM under a data key wrapped by a master key, write-only over the API, and redacted from logs and audit. Reusable credentials are first-class records. A connection references one by ID, a grantee connects through it without reading the value, and rotation updates one record instead of every connection.

Authorization combines Casbin RBAC with per-connection ownership and sharing grants. Every route carries a risk tier (safe, write, destructive, privileged), so policy expresses rules like Docker socket access being privileged by default, or destructive database statements needing confirmation. Admin is a user-management role, not a super user, and it grants no access to other people's connections, credentials, or recordings.

WebSocket upgrades carry no Authorization header, so streams open with a short-lived single-use ticket bound to the connection, route, params, and user. A ticket minted to exec into one pod fails when replayed against another.

Recording is plugin-declared and off by default. The core never records because a panel happens to be a terminal. Terminal sessions write asciicast v2 incrementally, with an environment allowlist in the header so a recording carries no secrets, and desktop sessions capture the browser canvas. Recordings stay private to their creator, admins included.

The AI assistant

The assistant reuses the route model instead of gaining its own access path. Connection routes become agent tools, invoked through the same pipeline as a click: same permission check, same risk tier, same audit event. Tool output is capped and truncated before it enters model context, with a note telling the model to narrow the query rather than silently losing rows. Read-only stream routes are sampled with a short timeout. The engine adapter stays confined to one package, so the provider choice never leaks into the rest of the gateway.

Screenshots

SSH terminal with split panes, a file browser, and snippets sharing one authenticated session.

ShellCN SSH terminal panel

MongoDB browsing with documents, indexes, validation, stats, and a console, rendered by the same generic table panel the SQL plugins use.

ShellCN MongoDB documents tab

An RDP session decoded server-side and bridged to the browser as RFB, with recording running.

ShellCN RDP desktop session

Constraints and tradeoffs

Remote desktops stay in-process. VNC streams raw RFB, and RDP is decoded by grdp, a pure Go client, then bridged to the noVNC engine in the browser. Adopting grdp puts the whole gateway under GPL-3.0, which was the price of keeping the single-binary rule. SPICE stays out of scope until a maintained browser client exists.

Multi-instance deployment works through store-backed live-state leases. Each connection's live state is pinned to the instance owning it, and other instances reverse proxy to the holder. Automatic failover is out of scope: when an owner dies its live sessions die with it and are re-established on next use.

Plugin distribution trust is only half solved. Checksums and CI-generated snapshots cover integrity and review, but binary signing and provenance for third-party plugins are still open. The project is early in general: the protocol set, the renderer, both transports, the marketplace, recording, and the assistant are in place, and OIDC, SAML, and LDAP login are still ahead.

Stack

  • Go 1.26, chi, coder/websocket, GORM behind repository interfaces
  • Pure Go drivers only so the binary stays free of CGO: modernc SQLite, pgx, MySQL
  • Casbin for RBAC and risk policy, local AES-256-GCM vault for secrets
  • hashicorp/go-plugin and gRPC for out-of-tree plugins, with a protobuf host contract
  • Vue 3, Vite, TypeScript, Pinia, PrimeVue unstyled with Tailwind
  • xterm.js for terminals, noVNC for desktops, CodeMirror for code and SQL
  • asciicast v2 for terminal recording, canvas capture for desktop sessions
  • Prometheus metrics, structured slog output, optional OpenTelemetry traces
Connect
Let's get in touch