A Go-Powered Desktop GUI for Sliver C2 Using Wails and gRPC
Sliver is an open-source adversary emulation framework. Its teamserver exposes a gRPC API, and operators usually drive it through the official CLI. sliver-gui takes a different route: a native desktop shell built in Go with Wails. No local REST proxy, no protocol translation layer. The Go backend speaks gRPC directly to the Sliver teamserver while the frontend renders in a webview.
The interesting part is not “Go can make a desktop app.” It is how little glue the project needs once Wails, generated protobuf clients, TLS credentials, and Sliver’s own Go packages are allowed to fit together.
Why Wails for a Go desktop app
Wails gives you a desktop application where the backend is Go and the frontend is web UI running in a native webview. Your Go code exposes methods that JavaScript can call directly, and Wails handles the bridge. For sliver-gui, that means the gRPC client layer stays in Go while the interface remains a normal frontend bundle.
If you’ve used Electron, the mental model is similar. But instead of a Node.js runtime as the backend process, you get a compiled Go binary. Smaller binary, lower memory footprint, and you don’t have to context-switch between two languages on the backend.
The project’s main.go is the entry point. It creates an App, passes it to wails.Run(), embeds the built frontend from frontend/dist, wires startup and shutdown hooks, and includes the app instance in Bind so exported Go methods are available to JavaScript.
The App struct and lifecycle in app.go
app.go defines the core App struct. In Wails, any struct you bind gets its exported methods exposed to the frontend. The App struct here is the coordinator — it holds a reference to the gRPC client, manages application state, and provides every method the UI needs to trigger actions.
Wails gives you lifecycle hooks, and this project uses startup and shutdown. The startup method receives a context.Context from Wails, and the App struct stores it. Runtime calls use that context, and most RPC methods pass it through to Sliver. If you’re fuzzy on how Go’s context package works, we covered it in detail in What is Context in Go?.
The pattern is straightforward: App is the orchestration layer. It owns the active client, protects it with a mutex, starts and stops the Sliver event stream, and adapts raw protobuf responses into frontend-friendly structs. The low-level connection setup stays in a separate package.
gRPC client code in internal/sliverclient/client.go
The real work lives in internal/sliverclient/client.go. This file handles connecting to Sliver’s gRPC API.
Sliver uses mutual TLS (mTLS) and, in current operator configs, an authorization token. The client needs a certificate, private key, CA certificate, host, port, and token from the .cfg file. client.go loads those values, constructs a tls.Config, adds gRPC interceptors for token metadata, and dials the teamserver with google.golang.org/grpc.
Here’s what a typical mTLS gRPC client setup looks like in Go:
import (
"crypto/tls"
"crypto/x509"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
func newTLSTransportCredentials(caCert, clientCert, clientKey []byte) (credentials.TransportCredentials, error) {
certPool := x509.NewCertPool()
certPool.AppendCertsFromPEM(caCert)
certificate, err := tls.X509KeyPair(clientCert, clientKey)
if err != nil {
return nil, err
}
tlsConfig := &tls.Config{
RootCAs: certPool,
Certificates: []tls.Certificate{certificate},
}
return credentials.NewTLS(tlsConfig), nil
}
That is the standard shape: build a tls.Config with client certificates and a custom CA pool, wrap it with credentials.NewTLS(), then pass it to grpc.Dial. sliver-gui has one important Sliver-specific wrinkle. Teamserver certificates may not have a hostname that matches the address being dialed, so the project disables the default hostname check and supplies VerifyPeerCertificate to verify the presented certificate against the teamserver CA instead. It also sets a minimum TLS version and raises gRPC message limits to handle large generated implants.
Once the connection is live, the client gets a generated gRPC stub from Sliver’s protobuf definitions and can call RPCs: listing sessions, listing operators, reading HTTP C2 profiles, generating implants, renaming sessions, and more. client.go wraps the connection and exposes focused methods that app.go can call.
Extension loading in extensions.go
extensions.go handles Sliver extensions — loadable modules that add capabilities to implants. The GUI needs to enumerate and manage these.
From a Go perspective, this file is more concrete than “list or load extensions.” It scans ~/.sliver-client/extensions, parses each extension.json, supports both single-command and multi-command manifest layouts, registers the right binary with the implant, packs BOF arguments using Sliver’s core.BOFArgsBuffer, and calls the relevant Sliver RPCs. The results come back as Go structs, and Wails serializes them for the frontend. You return a Go value; JavaScript receives ordinary data.
How Wails bridges Go and JavaScript
Worth understanding: when you pass a struct instance to the Bind option in wails.Run(), Wails uses reflection to discover all exported methods on that struct. Each one becomes callable from JavaScript.
There are constraints on method signatures. Return types must be serializable, and if a method returns an error, Wails turns it into a rejected Promise on the JavaScript side. So your Go code stays idiomatic:
func (a *App) GetSessions() ([]Session, error) {
sessions, err := a.client.ListSessions(a.ctx)
if err != nil {
return nil, fmt.Errorf("failed to list sessions: %w", err)
}
return sessions, nil
}
The frontend calls a bound method as an async function. It gets either the result or a rejected Promise with the error message. No HTTP handlers, no routers, no REST endpoints to wire up. The gRPC call fires, the result comes back, and Wails handles transport to the UI.
The Go module and dependencies
The go.mod reveals the dependency graph. The ones that matter:
github.com/wailsapp/wails/v2for the desktop frameworkgithub.com/bishopfox/sliverfor Sliver’s Go packages, including protobuf definitions and the gRPC client librarygoogle.golang.org/grpcfor the gRPC runtime
Pulling in Sliver as a Go module dependency is what makes this whole project feasible without protocol reimplementation. The protobuf message types, generated RPC client, and helper packages for extension support come from Sliver’s own codebase. sliver-gui imports them instead of maintaining a parallel API model.
This is Go’s module system doing exactly what it should. Instead of reverse-engineering the Sliver protocol or maintaining a separate API client, the project uses the real thing. One less thing to break when Sliver updates its API.
Patterns worth stealing
Context propagation. The Wails startup context is stored on App and passed into most RPC calls. The event stream gets its own cancellable child context, so reconnecting or disconnecting can stop the stream without tearing down the whole application context.
Separation of concerns. gRPC connection logic lives in internal/sliverclient/, while Wails lifecycle, event emission, sessions, listeners, tunneling, and extension workflows live on App. The internal package path prevents external consumers from importing the client package, which is a common Go convention for code that should not be part of your public API. We’ve discussed related patterns in the context of functional options, another approach to clean Go API design.
No intermediate API layer. A lot of desktop apps that wrap a server API introduce a REST proxy or WebSocket bridge between the backend and the service they’re talking to. sliver-gui skips all of that. The Go backend speaks gRPC natively, Wails handles Go-to-JavaScript bridging. Fewer moving parts, less latency, less code to maintain.
This architecture generalizes well
sliver-gui is a Sliver tool, but the architecture applies to any scenario where you need a desktop GUI over a gRPC service. Wails handles the Go-to-webview bridge, and Go’s module system lets you reuse existing client libraries instead of writing your own. The App struct coordinates application behavior, the internal client owns the gRPC connection, and Wails serializes the results.
What I find most interesting about this project isn’t any single technique. It’s that someone built a functional operator console with remarkably little glue code, because the tools fit together without fighting each other. If you’re wrapping a gRPC service and dreading the “now build a REST layer in front of it” step, consider whether you even need one.