Содержание

HTTP over SSH на Go

На удалённой машине работает веб-сервер, доступный только по адресу localhost и не принимающий подключения извне. Единственный способ подключиться к этой машине — через SSH.

Требуется реализовать в Go-приложении возможность отправлять HTTP-запросы к этому веб-серверу, используя SSH-туннелирование внутри самого приложения.

Для тестирования напишем небольшое приложение на 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 -

Веб сервер работает.

В первую нужно создать 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
}

В 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
}

Напишим простой пример для демонстрации работы.
Создаем 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)
	}
}

Полный код решения:

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)
	}
}

Собираем приложение и запускаем:

❯ 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 запрос к локальному веб серверу.

Запустим 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.

Wireshark обычно не установлен на удаленных хостах. Поэтому захватим все нужные пакеты на удаленном хосте в режиме реального времени перенаправлением из вывода tcpdump:

ssh root@remote-host  "tcpdump -i any -U -s0 -w - 'port 8000'" | wireshark -k -i -

В wireshark все те же данные, что и в tcdump, но wireshark удобнее использовать.

wireshark

Похожее