HTTP(S) прокси на Go в 100 строчек кода

4 minute read

Перевод “HTTP(S) Proxy in Golang in less than 100 lines of code

В этой статье я опишу реализацию HTTP и HTTPS прокси сервера. С HTTP все просто: сначала парсим запрос от клиента, передаем этот запрос дальше на сервер, получаем ответ от сервера и передаем его обратно клиенту. Нам достаточно использовать HTTP сервер и клиент из пакета net/http. С HTTPS все несколько сложнее. Технически это будет туннелирование HTTP с использованием метода CONNECT. Клиент отправляет запрос, указав метод CONNECT, с помощью которого устанавливается соединение между клиентом и удаленным сервером. Как только наш туннель из 2х TCP соединений готов, клиент обменивается TLS рукопожатием с сервером, посылает запрос и ждет ответ.

Сертификаты

Наш прокси будет работать как HTTPS сервер(если используется параметр —-proto https), а это значит нам нужны сертификаты и приватные ключи. В качестве примера будем использовать самоподписанные сертификаты, которые можно сгенерировать вот таким скриптом:

 1#!/usr/bin/env bash
 2case `uname -s` in
 3    Linux*)     sslConfig=/etc/ssl/openssl.cnf;;
 4    Darwin*)    sslConfig=/System/Library/OpenSSL/openssl.cnf;;
 5esac
 6openssl req \
 7    -newkey rsa:2048 \
 8    -x509 \
 9    -nodes \
10    -keyout server.key \
11    -new \
12    -out server.pem \
13    -subj /CN=localhost \
14    -reqexts SAN \
15    -extensions SAN \
16    -config <(cat $sslConfig \
17        <(printf '[SAN]\nsubjectAltName=DNS:localhost')) \
18    -sha256 \
19    -days 3650

Необходимо убедить вашу операционную систему доверять получившимся сертификатам. Для этого в OS X можно использовать Keychain Access.

HTTP

Для работы с HTTP будем использовать встроенный клиент и сервер. Прокся будет обрабатывать полученный запрос, передавать его нужному серверу и возвращать ответ клиенту.

   +------+        +-----+        +-----------+
   |client|        |proxy|        |destination|
   +------+        +-----+        +-----------+
1          --Req-->       
2                         --Req-->
3                         <--Res--
4          <--Res--

HTTP туннелирование с использованием CONNECT

Если мы хотим использовать HTTPS или WebSockets, то придется поменять тактику. Нам нужен метод HTTP CONNECT. Этот метод работает как приказ серверу установить TCP соединение с необходимым сервером и рулить TCP стримом между сервером и клиентом. В таком случае SSL не будет разрываться и все данные будут передаваться по этому своеобразному туннелю.

    +------+            +-----+                   +-----------+
    |client|            |proxy|                   |destination| 
    +------+            +-----+                   +-----------+
1           --CONNECT-->       
2                              <--TCP handshake-->
3           <--------------Tunnel---------------->

Реализация

 1package main
 2import (
 3    "crypto/tls"
 4    "flag"
 5    "io"
 6    "log"
 7    "net"
 8    "net/http"
 9    "time"
10)
11func handleTunneling(w http.ResponseWriter, r *http.Request) {
12    dest_conn, err := net.DialTimeout("tcp", r.Host, 10*time.Second)
13    if err != nil {
14        http.Error(w, err.Error(), http.StatusServiceUnavailable)
15        return
16    }
17    w.WriteHeader(http.StatusOK)
18    hijacker, ok := w.(http.Hijacker)
19    if !ok {
20        http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
21        return
22    }
23    client_conn, _, err := hijacker.Hijack()
24    if err != nil {
25        http.Error(w, err.Error(), http.StatusServiceUnavailable)
26    }
27    go transfer(dest_conn, client_conn)
28    go transfer(client_conn, dest_conn)
29}
30func transfer(destination io.WriteCloser, source io.ReadCloser) {
31    defer destination.Close()
32    defer source.Close()
33    io.Copy(destination, source)
34}
35func handleHTTP(w http.ResponseWriter, req *http.Request) {
36    resp, err := http.DefaultTransport.RoundTrip(req)
37    if err != nil {
38        http.Error(w, err.Error(), http.StatusServiceUnavailable)
39        return
40    }
41    defer resp.Body.Close()
42    copyHeader(w.Header(), resp.Header)
43    w.WriteHeader(resp.StatusCode)
44    io.Copy(w, resp.Body)
45}
46func copyHeader(dst, src http.Header) {
47    for k, vv := range src {
48        for _, v := range vv {
49            dst.Add(k, v)
50        }
51    }
52}
53func main() {
54    var pemPath string
55    flag.StringVar(&pemPath, "pem", "server.pem", "path to pem file")
56    var keyPath string
57    flag.StringVar(&keyPath, "key", "server.key", "path to key file")
58    var proto string
59    flag.StringVar(&proto, "proto", "https", "Proxy protocol (http or https)")
60    flag.Parse()
61    if proto != "http" && proto != "https" {
62        log.Fatal("Protocol must be either http or https")
63    }
64    server := &http.Server{
65        Addr: ":8888",
66        Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
67            if r.Method == http.MethodConnect {
68                handleTunneling(w, r)
69            } else {
70                handleHTTP(w, r)
71            }
72        }),
73        // Disable HTTP/2.
74        TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
75    }
76    if proto == "http" {
77        log.Fatal(server.ListenAndServe())
78    } else {
79        log.Fatal(server.ListenAndServeTLS(pemPath, keyPath))
80    }
81}

Предупреждаю, что это не готовый к продакшену код. Это только пример. В этом коде не хватает передачи необходимых hop-by-hop заголовков и правильной настройки таймаутов(об этом можно почитать в прекрасной статье “Руководство по net/http таймаутам в Go

Наша прокся будет поддерживать оба способа. По умолчанию будем работать по простой схеме, но создадим туннель если указан метод CONNECT

1http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2    if r.Method == http.MethodConnect {
3        handleTunneling(w, r)
4    } else {
5        handleHTTP(w, r)
6    }
7})

Функция handleHTTP очень простая, поэтому сконцентрируемся на handleTunneling. Все начинается с установки соединения:

1dest_conn, err := net.DialTimeout("tcp", r.Host, 10*time.Second)
2if err != nil {
3    http.Error(w, err.Error(), http.StatusServiceUnavailable)
4    return
5 }
6 w.WriteHeader(http.StatusOK)

Затем используем интерфейс Hijacker чтобы получить соединение с которым работает наш http сервер.

1hijacker, ok := w.(http.Hijacker)
2if !ok {
3    http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
4    return
5}
6client_conn, _, err := hijacker.Hijack()
7if err != nil {
8    http.Error(w, err.Error(), http.StatusServiceUnavailable)
9}

Если мы перехватываем соединение, то и обслуживать его дальше должны сами.

Теперь мы можем передавать данные напрямую между двумя TCP соединениями. Собственно, это и будет тем самым туннелем.

1go transfer(dest_conn, client_conn)
2go transfer(client_conn, dest_conn)

В этих рутинах данные передаются от клиента к серверу и обратно.

Проверяем

Чтобы проверить как все это работает можно использовать хром:

1chrome --proxy-server=https://localhost:8888

Или сurl:

1curl -Lv --proxy https://localhost:8888 --proxy-cacert server.pem https://google.com

Curl должен быть собран с поддержкой HTTPS-прокси

HTTP/2

К сожалению, у нас не получится так просто реализовать прокси для HTTP/2. Все дело в интерфейсе Hijacker. Подробности можно узнать тут #14797.