HTTP over SSH in Go

1 HTTP over SSH in Go
1.1 Task
A web server is running on a remote machine and is accessible only via localhost; it does not accept external connections. The only way to access this machine is through SSH.
The goal is to implement the ability to send HTTP requests to this web server from within a Go application by establishing an SSH tunnel programmatically.
2 Web Server
To test the setup, we’ll write a small Python application that prints the request data: the HTTP method, query parameters, and headers.
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
import json
class RequestHandler(BaseHTTPRequestHandler):
def do_GET(self):
parsed_url = urlparse(self.path)
query_params = parse_qs(parsed_url.query)
response = {
"method": "GET",
"path": parsed_url.path,
"query_params": query_params,
"headers": dict(self.headers)
}
self.send_response(200)
self.send_header('Content-type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps(response).encode('utf-8'))
def run_server(port=8000):
server_address = ('127.0.0.1', port)
httpd = HTTPServer(server_address, RequestHandler)
print(f'Start server on {port}')
httpd.serve_forever()
if __name__ == '__main__':
run_server()
Run the server and verify that the web server is working:
$ curl -s -G "http://localhost:8000/path" -H "Custom-Header: SomeValue" -d "env=dev" -d "name=test" | jq
{
"method": "GET",
"path": "/path",
"query_params": {
"env": [
"dev"
],
"name": [
"test"
]
},
"headers": {
"Host": "localhost:8000",
"User-Agent": "curl/8.12.1",
"Accept": "*/*",
"Custom-Header": "SomeValue"
}
}
In the server log, we see:
python3 ./main.py
Start server on 8000
127.0.0.1 - - [18/Jul/2025 20:15:41] "GET /?lang=go&query-arg=my-arg-1 HTTP/1.1" 200 -
The web server is running.
3 Go Application
3.1 SSH Client
First, we need to create an SSH
client.
We use the standard library golang.org/x/crypto/ssh
for SSH
functionality.
Specify the parameters: user
, host
, and key
, then create the client using ssh.Dial
. This is a standard practice.
func NewSshClient() (*ssh.Client, error) {
key, err := os.ReadFile(sshKeyPath)
if err != nil {
return nil, err
}
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return nil, err
}
sshConfig := &ssh.ClientConfig{
User: sshUser,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
sshAddr := fmt.Sprintf("%s:%d", sshHost, sshPort)
client, err := ssh.Dial("tcp", sshAddr, sshConfig)
if err != nil {
return nil, err
}
return client, nil
}
3.2 HTTP Transport
In Go, the http.Client
struct has a Transport
field, which implements the http.RoundTripper
interface and is responsible for sending HTTP requests.
To override the data transfer method, you can provide a custom implementation of the RoundTrip(*http.Request) (*http.Response, error)
method.
For the transport, we will use socat
launched inside an SSH session. The HTTP request is serialized and sent via stdin
to socat
; socat
forwards the data over TCP to the web server port. The HTTP response is read from socat
’s stdout
and deserialized into an http.Response
type.
This solution is not ideal and has many caveats:
socat
may not be installed on the remote system.socat
can be replaced withncat
, but there are timeout issues. You can explicitly set them withncat -q1
, but that’s more of a temporary workaround.- Most likely, a native way to transmit raw HTTP requests to the web server should be used. However, I couldn’t find a better option.
- Opening a new SSH session for every request for request isolation is not very efficient, but it works well enough as an example.
Code:
type SSHTransport struct {
sshClient *ssh.Client
httpAddr string
}
func (t *SSHTransport) RoundTrip(req *http.Request) (*http.Response, error) {
session, err := t.sshClient.NewSession()
if err != nil {
return nil, fmt.Errorf("failed to create SSH session: %w", err)
}
defer session.Close()
stdin, err := session.StdinPipe()
if err != nil {
return nil, fmt.Errorf("failed to get stdin pipe: %w", err)
}
var stdout bytes.Buffer
session.Stdout = &stdout
var stderr bytes.Buffer
session.Stderr = &stderr
if err := session.Start("socat - TCP:" + t.httpAddr); err != nil {
return nil, fmt.Errorf("failed to start socat: %w", err)
}
if err := req.Write(stdin); err != nil {
return nil, fmt.Errorf("failed to write request: %v", err)
}
if err := session.Wait(); err != nil {
return nil, fmt.Errorf("socat session error: %w", err)
}
if stderr.Len() > 0 {
log.Printf("stderr: %s", stderr.String())
return nil, fmt.Errorf("socat session error")
}
resp, err := http.ReadResponse(bufio.NewReader(&stdout), req)
if err != nil {
return nil, fmt.Errorf("failed to read response: %v", err)
}
return resp, nil
}
3.3 Combining the SSH Client and HTTP Client
Let’s write a simple example to demonstrate how it works.
Create an SSH
client. Execute test commands to verify that the SSH
session is working.
Create an HTTP
client with a custom transport implementation.
Perform a test HTTP
request using the standard library.
Code:
func main() {
log.SetFlags(log.Lmicroseconds)
client, err := NewSshClient()
if err != nil {
panic(err)
}
defer client.Close()
for _, cmd := range []string{"hostname", "uptime", "whoami"} {
session, err := client.NewSession()
if err != nil {
panic(err)
}
defer session.Close()
output, err := session.CombinedOutput(cmd)
if err != nil {
panic(err)
}
log.Printf("%s -> %s", cmd, output)
}
httpClient := &http.Client{
Transport: &SSHTransport{
sshClient: client,
httpAddr: httpAddr,
},
}
for _, arg := range []string{"my-arg-1"} {
req, err := http.NewRequest(http.MethodGet, "http://localhost", nil)
if err != nil {
panic(err)
}
req.Header.Add("Custom-Header", arg)
q := req.URL.Query()
q.Add("lang", "go")
q.Add("query-arg", arg)
req.URL.RawQuery = q.Encode()
resp, err := httpClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
panic(err)
}
log.Printf("Status: %s", resp.Status)
log.Printf("Body: %s", body)
}
}
3.4 Complete Solution
Full source code of the solution:
package main
import (
"bufio"
"bytes"
"fmt"
"io"
"log"
"net/http"
"os"
"golang.org/x/crypto/ssh"
)
const (
sshUser = "<user>"
sshHost = "<host>"
sshPort = 22
sshKeyPath = "<key>"
httpAddr = "localhost:8000"
)
type SSHTransport struct {
sshClient *ssh.Client
httpAddr string
}
func (t *SSHTransport) RoundTrip(req *http.Request) (*http.Response, error) {
session, err := t.sshClient.NewSession()
if err != nil {
return nil, fmt.Errorf("failed to create SSH session: %w", err)
}
defer session.Close()
stdin, err := session.StdinPipe()
if err != nil {
return nil, fmt.Errorf("failed to get stdin pipe: %w", err)
}
var stdout bytes.Buffer
session.Stdout = &stdout
var stderr bytes.Buffer
session.Stderr = &stderr
if err := session.Start("socat - TCP:" + t.httpAddr); err != nil {
return nil, fmt.Errorf("failed to start socat: %w", err)
}
if err := req.Write(stdin); err != nil {
return nil, fmt.Errorf("failed to write request: %v", err)
}
if err := session.Wait(); err != nil {
return nil, fmt.Errorf("socat session error: %w", err)
}
if stderr.Len() > 0 {
log.Printf("stderr: %s", stderr.String())
return nil, fmt.Errorf("socat session error")
}
resp, err := http.ReadResponse(bufio.NewReader(&stdout), req)
if err != nil {
return nil, fmt.Errorf("failed to read response: %v", err)
}
return resp, nil
}
func NewSshClient() (*ssh.Client, error) {
key, err := os.ReadFile(sshKeyPath)
if err != nil {
return nil, err
}
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return nil, err
}
sshConfig := &ssh.ClientConfig{
User: sshUser,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
sshAddr := fmt.Sprintf("%s:%d", sshHost, sshPort)
client, err := ssh.Dial("tcp", sshAddr, sshConfig)
if err != nil {
return nil, err
}
return client, nil
}
func main() {
log.SetFlags(log.Lmicroseconds)
client, err := NewSshClient()
if err != nil {
panic(err)
}
defer client.Close()
for _, cmd := range []string{"hostname", "uptime", "whoami"} {
session, err := client.NewSession()
if err != nil {
panic(err)
}
defer session.Close()
output, err := session.CombinedOutput(cmd)
if err != nil {
panic(err)
}
log.Printf("%s -> %s", cmd, output)
}
httpClient := &http.Client{
Transport: &SSHTransport{
sshClient: client,
httpAddr: httpAddr,
},
}
for _, arg := range []string{"my-arg-1"} {
req, err := http.NewRequest(http.MethodGet, "http://localhost", nil)
if err != nil {
panic(err)
}
req.Header.Add("Custom-Header", arg)
q := req.URL.Query()
q.Add("lang", "go")
q.Add("query-arg", arg)
req.URL.RawQuery = q.Encode()
resp, err := httpClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
panic(err)
}
log.Printf("Body: %s - %s", resp.Status, body)
}
}
4 Running
Build the application and run it:
❯ go build -o /tmp/service ./main.go && /tmp/service
23:15:40.445698 hostname -> remote-host
23:15:41.214312 uptime -> 20:15:39 up 10:53, 2 users, load average: 0.45, 0.33, 0.43
23:15:41.975354 whoami -> root
23:15:43.237978 Body: 200 - {"method": "GET", "path": "/", "query_params": {"lang": ["go"], "query-arg": ["my-arg-1"]}, "headers": {"Host": "localhost", "User-Agent": "Go-http-client/1.1", "Custom-Header": "my-arg-1"}}
Hooray! It worked.
The application connected to the remote server via SSH
and performed an HTTP
request to the local web server on that machine.
5 Network Traffic Analysis
5.1 tcpdump
Let’s run tcpdump
on the remote server and see what data was transmitted over the network to the web server:
tcpdump -i any -A -s 0 -X -nn -l port 8000
Establishing a TCP connection. The three-way handshake: SYN
, SYN
-ACK
, ACK
:
20:15:41.376906 lo In IP 127.0.0.1.46738 > 127.0.0.1.8000: Flags [S], seq 3363993963, win 65495, options [mss 65495,sackOK,TS val 3490232963 ecr 0,nop,wscale 8], length 0
20:15:41.376939 lo In IP 127.0.0.1.8000 > 127.0.0.1.46738: Flags [S.], seq 2708443969, ack 3363993964, win 65483, options [mss 65495,sackOK,TS val 3490232963 ecr 3490232963,nop,wscale 8], length 0
20:15:41.376962 lo In IP 127.0.0.1.46738 > 127.0.0.1.8000: Flags [.], ack 1, win 256, options [nop,nop,TS val 3490232963 ecr 3490232963], length 0
socat
sent the HTTP
request, server acknowledged receipt:
20:15:41.377051 lo In IP 127.0.0.1.46738 > 127.0.0.1.8000: Flags [P.], seq 1:121, ack 1, win 256, options [nop,nop,TS val 3490232963 ecr 3490232963], length 120
0x0000: 4500 00ac ccd1 4000 4006 6f78 7f00 0001 E.....@.@.ox....
0x0010: 7f00 0001 b692 1f40 c882 796c a16f 9342 .......@..yl.o.B
0x0020: 8018 0100 fea0 0000 0101 080a d008 ba83 ................
0x0030: d008 ba83 4745 5420 2f3f 6c61 6e67 3d67 ....GET./?lang=g
0x0040: 6f26 7175 6572 792d 6172 673d 6d79 2d61 o&query-arg=my-a
0x0050: 7267 2d31 2048 5454 502f 312e 310d 0a48 rg-1.HTTP/1.1..H
0x0060: 6f73 743a 206c 6f63 616c 686f 7374 0d0a ost:.localhost..
0x0070: 5573 6572 2d41 6765 6e74 3a20 476f 2d68 User-Agent:.Go-h
0x0080: 7474 702d 636c 6965 6e74 2f31 2e31 0d0a ttp-client/1.1..
0x0090: 4375 7374 6f72 6d2d 4865 6164 6572 3a20 Custom--Header:.
0x00a0: 6d79 2d61 7267 2d31 0d0a 0d0a my-arg-1....
20:15:41.377063 lo In IP 127.0.0.1.8000 > 127.0.0.1.46738: Flags [.], ack 121, win 256, options [nop,nop,TS val 3490232963 ecr 3490232963], length 0
Server sent the response, socat
acknowledged receipt:
20:15:41.377910 lo In IP 127.0.0.1.8000 > 127.0.0.1.46738: Flags [P.], seq 1:125, ack 121, win 256, options [nop,nop,TS val 3490232964 ecr 3490232963], length 124
0x0000: 4500 00b0 39ed 4000 4006 0259 7f00 0001 E...9.@.@..Y....
0x0010: 7f00 0001 1f40 b692 a16f 9342 c882 79e4 .....@...o.B..y.
0x0020: 8018 0100 fea4 0000 0101 080a d008 ba84 ................
0x0030: d008 ba83 4854 5450 2f31 2e30 2032 3030 ....HTTP/1.0.200
0x0040: 204f 4b0d 0a53 6572 7665 723a 2042 6173 .OK..Server:.Bas
0x0050: 6548 5454 502f 302e 3620 5079 7468 6f6e eHTTP/0.6.Python
0x0060: 2f33 2e36 2e31 350d 0a44 6174 653a 2046 /3.6.15..Date:.F
0x0070: 7269 2c20 3138 204a 756c 2032 3032 3520 ri,.18.Jul.2025.
0x0080: 3230 3a31 353a 3431 2047 4d54 0d0a 436f 20:15:41.GMT..Co
0x0090: 6e74 656e 742d 7479 7065 3a20 6170 706c ntent-type:.appl
0x00a0: 6963 6174 696f 6e2f 6a73 6f6e 0d0a 0d0a ication/json....
20:15:41.377926 lo In IP 127.0.0.1.46738 > 127.0.0.1.8000: Flags [.], ack 125, win 256, options [nop,nop,TS val 3490232964 ecr 3490232964], length 0
20:15:41.378014 lo In IP 127.0.0.1.8000 > 127.0.0.1.46738: Flags [P.], seq 125:316, ack 121, win 256, options [nop,nop,TS val 3490232964 ecr 3490232964], length 191
0x0000: 4500 00f3 39ee 4000 4006 0215 7f00 0001 E...9.@.@.......
0x0010: 7f00 0001 1f40 b692 a16f 93be c882 79e4 .....@...o....y.
0x0020: 8018 0100 fee7 0000 0101 080a d008 ba84 ................
0x0030: d008 ba84 7b22 6d65 7468 6f64 223a 2022 ....{"method":."
0x0040: 4745 5422 2c20 2270 6174 6822 3a20 222f GET",."path":."/
0x0050: 222c 2022 7175 6572 795f 7061 7261 6d73 ",."query_params
0x0060: 223a 207b 226c 616e 6722 3a20 5b22 676f ":.{"lang":.["go
0x0070: 225d 2c20 2271 7565 7279 2d61 7267 223a "],."query-arg":
0x0080: 205b 226d 792d 6172 672d 3122 5d7d 2c20 .["my-arg-1"]},.
0x0090: 2268 6561 6465 7273 223a 207b 2248 6f73 "headers":.{"Hos
0x00a0: 7422 3a20 226c 6f63 616c 686f 7374 222c t":."localhost",
0x00b0: 2022 5573 6572 2d41 6765 6e74 223a 2022 ."User-Agent":."
0x00c0: 476f 2d68 7474 702d 636c 6965 6e74 2f31 Go-http-client/1
0x00d0: 2e31 222c 2022 4375 7374 6f72 6d2d 4865 .1",."Custom--He
0x00e0: 6164 6572 223a 2022 6d79 2d61 7267 2d31 ader":."my-arg-1
0x00f0: 227d 7d "}}
20:15:41.378022 lo In IP 127.0.0.1.46738 > 127.0.0.1.8000: Flags [.], ack 316, win 256, options [nop,nop,TS val 3490232964 ecr 3490232964], length 0
Closing the TCP
connection. The server closes the connection and the client acknowledges. Then the client closes the connection and the server acknowledges:
20:15:41.378091 lo In IP 127.0.0.1.8000 > 127.0.0.1.46738: Flags [F.], seq 316, ack 121, win 256, options [nop,nop,TS val 3490232964 ecr 3490232964], length 0
20:15:41.418927 lo In IP 127.0.0.1.46738 > 127.0.0.1.8000: Flags [.], ack 317, win 256, options [nop,nop,TS val 3490233005 ecr 3490232964], length 0
20:15:41.878718 lo In IP 127.0.0.1.46738 > 127.0.0.1.8000: Flags [F.], seq 121, ack 317, win 256, options [nop,nop,TS val 3490233464 ecr 3490232964], length 0
20:15:41.878754 lo In IP 127.0.0.1.8000 > 127.0.0.1.46738: Flags [.], ack 122, win 256, options [nop,nop,TS val 3490233465 ecr 3490233464], length 0
Overall, everything is as expected — standard operations with nothing extra.
The web server and socat
were on the same network and communicated through localhost.
5.2 Wireshark
Wireshark
is usually not installed on remote hosts. Therefore, we capture all necessary packets on the remote host in real-time by streaming output from tcpdump
:
ssh root@remote-host "tcpdump -i any -U -s0 -w - 'port 8000'" | wireshark -k -i -
In Wireshark
, you get the same data as in tcpdump
, but Wireshark
is more convenient to use.
wireshark