HTTP(S) прокси на Go в 100 строчек кода
Перевод “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.