HTTP over SSH на Go

1 HTTP over SSH на Go
1.1 Задача
На удалённой машине работает веб-сервер, доступный только по адресу localhost и не принимающий подключения извне. Единственный способ подключиться к этой машине — через SSH.
Требуется реализовать в Go-приложении возможность отправлять HTTP-запросы к этому веб-серверу, используя SSH-туннелирование внутри самого приложения.
2 Веб сервер
Для тестирования напишем небольшое приложение на python, которое выводит данные запроса: метод, параметры запроса, заголовки.
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()
Запускаем и проверяем, что веб сервер работает:
$ 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"
}
}
В логе сервера видим:
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 -
Веб сервер работает.
3 Приложение на Go
3.1 ssh-клиент
В первую нужно создать ssh клиента.
Используем стандартную библиотеку golang.org/x/crypto/ssh
для работы с ssh.
Задаем параметры пользователь
, хост
, ключ
и создаем клиента через ssh.Dial
. Стандартная практика.
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-транспорт
В Go у структуры http.Client
есть поле Transport
, которое реализует интерфейс http.RoundTripper
и отвечает за отправку HTTP-запросов.
Для переопределения способа передачи данных можно задать собственную реализацию метода RoundTrip(*http.Request) (*http.Response, error)
.
В качестве транспорта будем использовать socat
, запущенный в ssh
сессии. http-запрос сериализуется, передается через stdin
в socat
, socat
отправляет данные по tcp
на порт веб сервера, ответ http-запроса читается из stdout
тулзы socat
и десериализуется в тип http.Response
.
Решение не идеальное, много нюансов:
socat
может отсутствовать на удаленной системе.socat
можно заменить наncat
, но тут возникают проблемы с таймаутами. Можно явно задать черезncat -q1
, но это больше как временное решение.- Скорее всего нужно использовать какой-то нативный способ передачи сырого http-запроса веб серверу. Но я ничего лучше не нашел.
- Новая ssh-сессия на каждый запрос для изолированности запросов не совсем рационально, но для примера подойдет.
Код:
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 Объединяем ssh клиента и http клиента
Напишим простой пример для демонстрации работы.
Создаем ssh-клиента. Выполняем тестовые команды, чтобы проверить, что ssh-сессия работает.
Создаем http-клиента с переопределенным способом передачи данных.
Выполняем тестовый http-запрос средствами стандартной библиотеки.
Код:
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 Полное решение
Полный код решения:
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 Запуск
Собираем приложение и запускаем:
❯ 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"}}
Ура! Все получилось.
Приложение подключилось по ssh к удаленному серверу, и на нем выполнило http запрос к локальному веб серверу.
5 Анализ сетевого трафика
5.1 tcpdump
Запустим tcpdump
на удаленном сервере и посмотрим, какие данные передавались по сети к веб серверу:
tcpdump -i any -A -s 0 -X -nn -l port 8000
Установка tcp соединения. Тройное рукопожатие, 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
отправил http запрос, сервер подтвердил получение:
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
Сервер отправил ответ, socat
подтвердил получение:
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
Завершение tcp соединения. Сервер закрывает соединение, клиент подтверждает. Клиент закрывает соединение, сервер подтверждает:
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
В целом все ожидаемо, стандартные операции, ничего лишнего.
Веб сервер и socat
были в одной сети, общались через localhost.
5.2 wireshark
Wireshark обычно не установлен на удаленных хостах. Поэтому захватим все нужные пакеты на удаленном хосте в режиме реального времени перенаправлением из вывода tcpdump:
ssh root@remote-host "tcpdump -i any -U -s0 -w - 'port 8000'" | wireshark -k -i -
В wireshark все те же данные, что и в tcdump, но wireshark удобнее использовать.
wireshark