Using Reverse WebSocket Connections in Go to Reach Services Behind NAT - Sat, Mar 8, 2025
Introduction
Have you ever needed to access a service running on a machine behind NAT—one where you can’t simply open a port and connect from the outside? This scenario is very common for remote management or “agent” scenarios, but not only that. Other use cases might be:
- Sharing a local VNC server port to provide remote assistance (similar to RustDesk / TeamViewer)
- Accessing IoT devices in a private network
- Performing maintenance on equipment in locked-down environments
In all these cases, the challenge is the same: inbound connections into a private network are typically blocked. A reverse WebSocket connection can solve this by having the private-network service (the “client”) initiate an outbound WebSocket connection to a publicly accessible server. Once established, the server can effectively send requests “back” over that channel.
In this post, we’ll walk through a minimal example using two Go applications:
Server
(publicly reachable) that accepts a WebSocket connection from the private-network machine:- Listens for the client on a
/ws
endpoint (WebSocket). - Provides an HTTP route at
/client1
. When you visithttp://server:8080/client1
, the server sends a request over the WebSocket to the client. The client fetches its local page and returns it. The server then relays that page back to the browser.
- Listens for the client on a
Client
(behind NAT) that connects out to the server, then listens for requests forwarded from the server—like a small tunnel:- Serves a simple “Hello Socket” page on
localhost:8888
. - Establishes a WebSocket connection to the Server.
- Waits for “forward” requests from the Server, then fetches the page at
localhost:8888
and sends it back.
- Serves a simple “Hello Socket” page on
When you run these two programs, any request to http://your-public-server:8080/client1
will effectively serve content from the client’s local :8888
page — even though the client is behind NAT or a firewall.
We’ll use the popular Gorilla WebSocket library to handle WebSocket connections, and Cobra to provide a simple CLI in the client, if you want to test the example on something different than localhost.
This basic pattern can be adapted to share any TCP-based service—like a VNC server for remote desktop/assistance. All you need is a small piece of logic in the client that proxies traffic from the local service (on the private network) back to the server.
Source code of the server (server/main.go
):
package main
import (
"log"
"net/http"
"sync"
"github.com/gorilla/websocket"
"github.com/spf13/cobra"
)
var (
// Cobra flags
port string
rootCmd = &cobra.Command{
Use: "server",
Short: "Reverse WebSocket server",
Run: runServer,
}
)
var upgrader = websocket.Upgrader{
// For the demo, allow any origin :)
CheckOrigin: func(r *http.Request) bool { return true },
}
var (
clientConn *websocket.Conn
connMutex sync.Mutex
)
func init() {
// Define flags for the server command
rootCmd.Flags().StringVar(&port, "port", "8080", "Port to listen on")
}
func runServer(cmd *cobra.Command, args []string) {
// HTTP handlers
http.HandleFunc("/ws", handleWebSocket)
http.HandleFunc("/client1", handleClient1)
addr := ":" + port
log.Printf("Server listening on %s\n", addr)
log.Fatal(http.ListenAndServe(addr, nil))
}
// handleWebSocket upgrades an HTTP request to a WebSocket
// and stores the connection for later use.
func handleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("WebSocket upgrade error:", err)
return
}
connMutex.Lock()
clientConn = conn
connMutex.Unlock()
log.Println("Client connected via WebSocket")
}
// handleClient1 handles an HTTP GET request to /client1.
// It forwards the request to the client via WebSocket, then relays the client's response.
func handleClient1(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Unsupported method", http.StatusMethodNotAllowed)
return
}
connMutex.Lock()
defer connMutex.Unlock()
if clientConn == nil {
http.Error(w, "No client connected", http.StatusServiceUnavailable)
return
}
// Send a message to the client to fetch its local page
err := clientConn.WriteMessage(websocket.TextMessage, []byte("FETCH_PAGE"))
if err != nil {
log.Println("Error sending WebSocket message:", err)
http.Error(w, "Failed to send request to client", http.StatusInternalServerError)
return
}
// Read the response from the client (the HTML content)
_, resp, err := clientConn.ReadMessage()
if err != nil {
log.Println("Error reading from WebSocket:", err)
http.Error(w, "Failed to get response from client", http.StatusInternalServerError)
return
}
// Write the response back to the caller
w.Header().Set("Content-Type", "text/html")
w.Write(resp)
}
func main() {
// Run the Cobra command
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
}
And the client (client/main.go
):
package main
import (
"fmt"
"io"
"log"
"net/http"
"net/url"
"github.com/gorilla/websocket"
"github.com/spf13/cobra"
)
var (
// Cobra flags
serverAddr string
localPort string
rootCmd = &cobra.Command{
Use: "client",
Short: "A reverse WebSocket client that serves a Hello page locally",
Run: runClient,
}
)
func init() {
rootCmd.Flags().StringVar(&serverAddr, "server", "localhost:8080", "WebSocket server address")
rootCmd.Flags().StringVar(&localPort, "local-port", "8888", "Local port to serve the Hello Socket page")
}
func main() {
// Execute the Cobra command
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
}
// runClient starts a local HTTP server that serves a Hello Socket page,
// then connects to the remote server via WebSocket and waits for requests.
func runClient(cmd *cobra.Command, args []string) {
// 1. Start the local page server in a goroutine
go startLocalPageServer(localPort)
// 2. Connect to the public server via WebSocket
u := url.URL{Scheme: "ws", Host: serverAddr, Path: "/ws"}
log.Printf("Connecting to server WebSocket at %s\n", u.String())
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
log.Fatal("WebSocket dial error:", err)
}
defer conn.Close()
// 3. Listen for incoming WebSocket messages (requests from the server)
for {
_, msg, err := conn.ReadMessage()
if err != nil {
log.Println("Read error from server WebSocket:", err)
return
}
switch string(msg) {
case "FETCH_PAGE":
respondWithLocalPage(conn, localPort)
default:
log.Println("Received unknown message:", string(msg))
}
}
}
// startLocalPageServer starts a simple server on the specified port
// that returns "Hello Socket" HTML content.
func startLocalPageServer(port string) {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "<html><body><h1>Hello from the client behind NAT!</h1></body></html>")
})
addr := ":" + port
log.Printf("Local client page server listening on %s\n", addr)
if err := http.ListenAndServe(addr, nil); err != nil {
log.Fatalf("Failed to start local client server: %v", err)
}
}
// respondWithLocalPage fetches the local "Hello Socket" page and sends it back over WebSocket.
func respondWithLocalPage(conn *websocket.Conn, port string) {
resp, err := http.Get("http://localhost:" + port + "/")
if err != nil {
log.Println("Error fetching local page:", err)
conn.WriteMessage(websocket.TextMessage, []byte("Error fetching local page"))
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Println("Error reading local page content:", err)
conn.WriteMessage(websocket.TextMessage, []byte("Error reading local page"))
return
}
// Send the page content back to the server
err = conn.WriteMessage(websocket.TextMessage, body)
if err != nil {
log.Println("Error sending page content to server:", err)
}
}
Testing the setup
This basic pattern can be adapted to share any TCP-based service—like a VNC server for remote desktop/assistance or SSH. All you need is a small piece of logic in the client that proxies traffic from the local service (on the private network) back to the server.
Comments