Vamos fazer um proxy reverso em Gox?
Objetivos
- Recebe e envia HTTP 1.1
- Não se preocupa com método de requisição
- Por exemplo, GET não tem body
- Métodos HTTP fora do standard (
MKCOL
, por exemplo, ouQUERY
, futuro)
- Não fazer buffer a priori de todo o body antes de fazer a requisição
- reencaminhar parte significativa do PATH
- Zero dependências externas
Hello world
Existem algumas maneiras para se estabelecer um servidor em Go para servir conexões HTTP. As mais simples dela envolvem o server padrão, o DefaultServerMux
.
Basicamente, você simplesmente pede para manusear a chamada HTTP, e pode ser de dois jeitos:
-
http.HandleFunc
: aqui você só passa uma função que vai lidar com suas questões -
http.Handle
: aqui você passa um objeto que possui o método públicoServeHTTP
adequado para a interfacehttp.Handler
Depois de você pedir para o servidor HTTP lidar com as requisições passando o handler
, você precisa criar um tipo adequado. Por exemplo, adaptado da documentação:
package main import ( "fmt" "html" "log" "net/http" ) type hello struct { } func (s hello) ServeHTTP(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Hello, %q\n", html.EscapeString(r.URL.Path)) } func main() { fooHandler := hello{} http.Handle("/oie", fooHandler) log.Fatal(http.ListenAndServe(":8080", nil)) }
Go até aceita que você cria structs anônimas, e pode atribuir a elas campos como funções. Mas nesse caso aqui uma interface não espera um campo (diferente de uma interface no TS), mas sim métodos:
package main import ( "fmt" "html" "log" "net/http" ) func main() { fooHandler2 := struct{ ServeHTTP func (w http.ResponseWriter, r *http.Request) } { func (w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Hello, %q\n", html.EscapeString(r.URL.Path)) }, } http.Handle("/oie", fooHandler2) log.Fatal(http.ListenAndServe(":8080", nil)) }
Isso gera esse erro:
Escutar e servir
Após adicionar os http.Handle
ou http.HandleFunc
, pedir por http.ListenAndServe
irá levantar o servidor na porta adequada.
Claro que você também tem a opção de prover a sua própria implementação do servidor HTTP, não restrito ao DefaultServerMux
. Por exemplo:
s := &http.Server{ Addr: ":8080", //... params } log.Fatal(s.ListenAndServe())
Então, temos este "endereço". Que no http.ListenAndServe
é passado como parâmetro, já no (*http.Server) ListenAndServe
é um campo de http.Server
. Ele serve para indicar o quê, afinal?
Além de indicar a porta, ele também indica como o servidor pode ser acessado. Por exemplo:
log.Fatal(http.ListenAndServe(":8080", nil))
Torna o servidor acessível através do meu celular, na mesma rede:
Porém, uma pequena mudança torna isso inacessível:
E qual foi a mudança que fez isso? Simplesmente colocar como o endereço localhost:8080
:
log.Fatal(http.ListenAndServe("localhost:8080", nil))
Isso serve para fechar o servidor a escutar uma requisição pelo endereço usado para identificar o servidor. Colocar o algo antes da porta, eu impeço que seja escutado quando a requisição não chega usando o DNS adequado.
Uma revisão em HTTP
Recentemente escrevi um post sobre HTTP, mas o foco da postagem foi mais uma pegada geral sobre solução de problemas e estratégia de estudo. Em breve retornarei a escrever sobre HTTP em minúncias, especificando o que implementei e como.
Uma mensagem HTTP é basicamente dividida em duas partes:
- uma requisição
- uma resposta
Ambas as partes da mensagem HTTP1.1 são divididas, em grosso modo, da mesma maneira:
- uma primeira linha (aqui requisição e resposta são absurdamente distintas)
- headers (até uma linha em branco)
- body
- headers de fim (apenas para chunked encoding, omitido na explicação abaixo)
Na requisição, a primeira linha consiste das seguintes partes (separadas por espaço):
- método
- path
- protocolo
Por exemplo:
GET /oie HTTP/1.1
Isso significa que o agente (normalmente o navegador) está requisitando com GET
a página de caminho /oie
para o servidor, usando o protocolo HTTP/1.1
(que eu grafei anteriormente como HTTP1.1, mas aqui é mais estrito o uso).
Isso serve para diferenciar, por exemplo, de quando a requisição é o clássico HTTP/1.0
. Para simplesmente receber uma requisição de modo similar ao HTTP 1.X, o HTTP2 usa como primeira linha:
PRI * HTTP/2.0
Que não tem nenhuma semântica. HTTP2 usa outra estratégia para as informações de requisição, nominalmente chamdas de pseudo-header :method
e :path
.
Após essa linha, vem os headers. Um header tem pela RFC um jeito bem flexível de ser declarado, mas costumeiramente é contido em uma linha (inclusive descobri escrevendo este post que um header ocupando diversas linhas foi marcado como deprecado na RFC 7230).
Basicamente, um header tem o seguinte formato:
header: value
E o header pode aparecer várias vezes durante a listagem:
header1: value header2: value for 2 header1: other value for 1
No caso de envios "repetidos" de headers, o recipiente da mensagem precisa interpretar como se fosse uma lista continuada, algo mais ou menos assim:
{ header1: ["value", "other value for 1"], header2: "value for 2" }
ou assim:
{ header1: "value, other value for 1", header2: "value for 2" }
E como se chega no final dos headers? Com uma linha em branco. Após a linha em branco, se tiver alguma mensagem para ser enviada, ela estará presente.
Então, após a linha em branco, vemos o corpo. E o modo como o corpo será transportado vai ter algumas características próprias. Basicamente, as 3 estratégias são:
- tamanho conhecido
- chunked
- multipart boundary
O envio de tamanho conhecido é basicamente informar que vai enviar N bytes e enviar esses N bytes. O chunked é enviar em pequenos pedaços, em chunks, indicando um chunk especial de tamanho 0, similar ao caracter nulo que termina strings em C. Em transferências multipart, é usado o limitador (boundary) para separar uma parte de outra, e um indicador no boundary é usado para indicar o final da mensagem, mas transferências multipart são usadas dentro do contexto de tamanho conhecido, sendo os boundaries computados para o tamanho total do conteúdo.
E, bem, e quanto à resposta do HTTP? Basicamente tudo que foi dito continua válido para a resposta, mas aqui precisamos distinguir uma coisa: a primeira linha.
Enquanto que na requisição a primeira linha consistia de método, caminho e protocolo, aqui a primeira linha consiste de 3 partes:
- protocolo
- código de status
- uma razão
Na prática, a razão muitas vezes é um mnemônico relativo ao status code. Por exemplo, 200 OK
.
Headers especiais
Meu foco é HTTP 1.1. E ele possui alguns headers especiais que fazem parte do protocolo, ou de negociação de conteúdo. Alguns desses headers já foram aludidos:
Content-length
Transfer-encoding
O Content-length
vai indicar o tamanho do conteúdo sendo trafegado, exceto se for usado Transfer-encoding: chunked
, onde o tamanho não é determinado priori e se precisa usar uma estratégia de streaming específica.
Outro header que pode alterar o como as coisas são transferidas é o Content-type
, quando o tipo é multipart/*
. Nesse caso, é usada uma estratégia de streaming própria. Mas, as partes relativas ao boundary são partes inerentes da transferência, computado no tamanho total. Portanto, posso ignorar totalmente isso e considerar apenas como um grande transporte.
Outro header especial é a requisição Connection: keep-alive
. Basicamente isso quer dizer que o cliente quer manter a conexão aberta, inclusive isso foi utilizado para resolver problemas de performance web, vide O dia em que precisei estudar HTTP, para otimizar a aplicação.
Junto do connection: keep-alive
da requisição, temos o connection: closed
da resposta. Isto é definido salto a salto, então este header não precisa ser repassado adiante, apenas gerenciado internamente.
Devido a questões de virtual servers e uso da mesma máquina para responder diversas requisições, também tem a questão de enviar o Host
como header mandatório.
O header TE
tem a particularidade no contexto da requisição. Ao ser usado o valor trailers
, indica que o cliente aceita receber headers após o body. Então, como eu não quero lidar com isso, vou tratar de suprimir esse valor.
Tem também o Forwarded
, indicado para proxies que querem permitir que o servidor que está recebendo a requisição saiba de onde ela partiu, para a partir disso podeer tomar alguma decisão. Normalmente essa informação é injetada pelo agente de proxy (RFC 7239).
Lista de headers
- Content-length
- Transfer-encoding
- Valor: chunked
- Connection
- Valor: keep-alive, close
- Host
- TE: trailers
- Forwarded
- injetado pelo proxy
Como funciona em Go? Servidor
Vamos começar com a requisição. Não precisamos nos preocupar exatamente com o como as informações são empacotadas, e como é a requisição não preciso nem me preocupar na ordem em que elas são apresentadas.
Em Go, usando o servidor HTTP padrão provido pela linguagem, eu posso simplesmente encaminhar a requisição. Para isso eu preciso ter um cliente HTTP, mas isso será visto mais tarde. Preciso me atentar a algumas coisas na hora de passar a requisição adiante:
- devo ignorar totalmente a questão do
Connection
, isso é algo salto a salto - lidar corretamente com o streaming de dados chunked
- header de
Host
adequado para o encaminhamento
Além disso, a RFC sugere fortemente o Forwarded
ao usar proxies, para que o server ainda tenha condição de saber quem fez a requisição original.
Além disso, vem bem a calhar a suprimir TE: trailers
. Ou então lidar com isso, mas agora parece uma complicação opcional a mais. Quem sabe um outro momento?
Como servidor, eu tenho acesso ao cmapo request.Body
, que implementa a interface io.Reader
. E posso usar o método Read
dele. Exemplificando uma leitura completa:
body := r.Body size := 0 buffer := make([]byte, 512*1024) completo := make([]byte, 0) for { n, err := body.Read(buffer) fmt.Printf("leitura parcial <%s>\n", string(buffer[:n])) if n > 0 { completo = append(completo, buffer[:n]...) size += n } if err == io.EOF { break } if err != nil { return } } fmt.Printf("body completo <%q>\n", string(completo))
Adaptado de Golang Cafe
Basicamente a ideia é ler em um buffer até que nenhum byte mais esteja presente. A leitura vai ocorrer até que err == EOF
, situação em que Go indica final da leitura.
No meu caso, eu fiquei concatenando a leitura no slice completo
. Para fazer isso corretamente, eu preciso chamar append(slice, elements...)
. Mas o buffer
que estou guardando as coisas é um slice, não um ...
. Como resolver isso? Usando o spread: completo = append(completo, buffer[:n]...)
. Adaptei desta resposta no StackOverflow. O Geeks for Geeks oferece também algumas estratégias de fazer a cópia de um slice em outro, e explica alguns detalhes no caminho, How to copy one slice into another slice in Golang. Note que não pego buffer
inteiro, apenas a parte que foi lida de buffer
, que é o slice buffer[:n]
.
Fazendo uma leitura enviando 5 bytes por vez, com o corpo tralala
, obtive esses logs:
leitura parcial <trala> leitura parcial <la> e aí? body <"tralala">
Para pegar o método, eu posso pedir para a requisição r.Method
. Ele retorna até métodos fora do convencional. Por exemplo, você pode pedir para o curl
fazer uma requisição HTTP usando um método totalmente inovador usando a opção de CLI -X método
. No caso, testei com -X PRRRR
, gerando o método PRRRR
.
Já na hora de escrever a resposta eu preciso me preocupar. Para escrever:
- protocolo + status + razão
- headers
- body
O protocolo vai ser inserido automaticamente pela biblioteca padrão, então preciso de uma alternativa para o código de status HTTP. E o ResponseWriter
tem uma alternativa pra isso. Por exemplo, simulando um status de Forbidden
:
w.WriteHeader(http.StatusForbidden)
O cuidado a se tomar é que chamar isto deve ser a primeira coisa. Logo depois, vem os headers. Certo isso? Bem... descobri que não. Da documentação de ResponseWriter.Header()
:
Header returns the header map that will be sent by
ResponseWriter.WriteHeader
. TheHeader
map also is the mechanism with whichHandler
implementations can set HTTP trailers.Changing the header map after a call to
ResponseWriter.WriteHeader
(orResponseWriter.Write
) has no effect unless the HTTP status code was of the 1xx class or the modified headers are trailers.
Ou seja: os headers vão ser escritos junto do status com WriteHeader
.
Usando o exemplo acima, para fazer uma transmissão com Transfer-Encoding: chunked
:
w.Header().Add("transfer-encoding", "chunked") w.WriteHeader(http.StatusForbidden) w.Write([]byte("lalala\n")) w.Write([]byte("lelele\n")) w.Write([]byte("lilili\n")) fmt.Fprintf(w, "Hello, %q\n", html.EscapeString(r.URL.Path))
Fazendo a leitura em curl:
> curl localhost:8080/oie -v -X PRRRR -d "tralala" * Host localhost:8080 was resolved. * IPv6: ::1 * IPv4: 127.0.0.1 * Trying [::1]:8080... * Connected to localhost (::1) port 8080 > PRRRR /oie HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/8.7.1 > Accept: */* > Content-Length: 7 > Content-Type: application/x-www-form-urlencoded > * upload completely sent off: 7 bytes < HTTP/1.1 403 Forbidden < Date: Tue, 18 Mar 2025 02:56:14 GMT < Transfer-Encoding: chunked < lalala lelele lilili Hello, "/oie" * Connection #0 to host localhost left intact
Hmmm, mas isso não me dá a visão dos chunks. Uma resposta do próprio Bagder indica como obter os chunks, só usar --raw
:
> curl localhost:8080/oie -v -X PRRRR -d "tralala" --raw * Host localhost:8080 was resolved. * IPv6: ::1 * IPv4: 127.0.0.1 * Trying [::1]:8080... * Connected to localhost (::1) port 8080 > PRRRR /oie HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/8.7.1 > Accept: */* > Content-Length: 7 > Content-Type: application/x-www-form-urlencoded > * upload completely sent off: 7 bytes < HTTP/1.1 403 Forbidden < Date: Tue, 18 Mar 2025 02:56:14 GMT < Transfer-Encoding: chunked < 23 lalala lelele lilili Hello, "/oie" 0 * Connection #0 to host localhost left intact
Hmmm, não mandou chunks pequenos... e se eu quiser chunks pequenos? Bem, segundo esta resposta, o ResponseWriter
que nos é informado normalmente também implementa http.Flusher
. Posso usar o casting para esse fim:
flusher := w.(http.Flusher)
Ou então daria para também se preparar no caso de não ser um http.Flusher
:
type fakeflusher struct { } func (f fakeflusher) Flush() { } flusher, ok := w.(http.Flusher) if !ok { fmt.Println("response not a flusher") flusher = fakeflusher{} }
E aqui estou garantindo que eu possa sempre chamara os métodos de Flush()
, pois estou criando um objeto que implementa a interface http.Flusher
, afinal ele é da struct fakeFlusher
.
Com o flusher
em mãos, podemos pedir para que ele dê um flush no que tá retido:
w.Header().Add("transfer-encoding", "chunked") w.WriteHeader(http.StatusForbidden) w.Write([]byte("lalala\n")) flusher.Flush() w.Write([]byte("lelele\n")) flusher.Flush() w.Write([]byte("lilili\n")) flusher.Flush() fmt.Fprintf(w, "Hello, %q\n", html.EscapeString(r.URL.Path))
E isso me dá a seguinte chamada curl:
> curl localhost:8080/oie -v -X PRRRR -d "tralala" --raw * Host localhost:8080 was resolved. * IPv6: ::1 * IPv4: 127.0.0.1 * Trying [::1]:8080... * Connected to localhost (::1) port 8080 > PRRRR /oie HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/8.7.1 > Accept: */* > Content-Length: 7 > Content-Type: application/x-www-form-urlencoded > * upload completely sent off: 7 bytes < HTTP/1.1 403 Forbidden < Date: Tue, 18 Mar 2025 02:56:14 GMT < Transfer-Encoding: chunked < 7 lalala 7 lelele 7 lilili e Hello, "/oie" 0 * Connection #0 to host localhost left intact
E agora foi possível perceber perfeitamente a separação em cada um dos chunks transferido, e o chunk terminador de tamanho 0.
Como funciona em Go? Cliente
Para criar uma conexão, a documentação indica usar http.NewRequest
e adicionar headers usando req.Header.Add()
:
req, err := http.NewRequest("GET", "http://example.com", nil) // método reader do body // url alvo da API req.Header.Add("Header", "value for header")
Para fazer uma requisição completamente arbitrária, é preciso criar um http.Client
, e também na documentação ele cita sobre passar o net.Transport
para construir o client:
tr := &http.Transport{ MaxIdleConns: 10, IdleConnTimeout: 30 * time.Second, DisableCompression: true, } client := &http.Client{Transport: tr}
E a requisição é um simples client.Do(req)
. Por exemplo, para fazer uma chamada para o Computaria em ambiente de desenvolvimento:
tr := &http.Transport{ MaxIdleConns: 10, IdleConnTimeout: 30 * time.Second, DisableCompression: true, } client := &http.Client{Transport: tr} func fullRead(r io.Reader) (string, error) { size := 0 buffer := make([]byte, 512*1024) completo := make([]byte, 0) for { fmt.Println("entrando na leitura") n, err := r.Read(buffer) fmt.Printf("leitura parcial %s\n", string(buffer[:n])) if n > 0 { completo = append(completo, buffer[:n]...) size += n } if err == io.EOF { break } if err != nil { return string(completo), err } } return string(completo), nil } req, err := http.NewRequest("GET", "http://localhost:4000/blog/", nil) if err != nil { fmt.Println("deu ruim criando a req") return } rcvd, err := client.Do(req) if err != nil { fmt.Println("deu ruim lendo a resposta") return } str, err := fullRead(rcvd.Body) if err != nil { fmt.Println("não leu tudo, portencialmente", str) return } fmt.Print(str)
E voi là, a página foi escrita no console.
Escrevendo o proxy
Como o Computaria está todo pronto para responder usando como primeiro caminho /blog
, vou manter isso no meu proxy. Reescrever essa parte seria bem complicado, pois envolveria ou interceptar a resposta e identificar os links dentro da resposta ou tornar o blog aware de que responderia com outra path base.
O proxy vai implementar http.Handler
. Vai bem a calhar que o proxy tenha 3 atributos:
- o prefixo que ele vai servir (no caso,
/blog/
) - o endereço que vai bater (no caso,
http://localhost:4000/blog/
) - um cliente HTTP já montado
Assim, temos a estrutura proxy
definida assim:
type proxy struct { client *http.Client target string preffix string } tr := &http.Transport{ MaxIdleConns: 10, IdleConnTimeout: 30 * time.Second, DisableCompression: true, } client := &http.Client{Transport: tr} proxyHandler := proxy{client, "http://localhost:4000/blog/", "/blog/"} http.Handle("/blog/", proxyHandler) log.Fatal(http.ListenAndServe(":8080", nil))
Ok, agora preciso definir como lidar com a requisição. De modo geral, vou pegar as coisas de uma requisição recebida e escrever na requisição a ser feita. Isso inclui método, path (após o prefixo), body, etc. No caso de headers que preciso tomar um pouco de cuidado: quero poder ignorar headers de trailer (portanto vou remover o TE: trailers
quando presente), e preciso ignorar Connection
e também Host
.
É de bom tom adicionar o Forwarded
. Na requisição tem o campo r.RemoteAddr
, que traz o endereço de quem se conectou e também a porta utilizada. Segundo a especificação, essa porta não é relevante, então preciso limpar ela:
func noPort(remoteAddr string) string { for idx, c := range remoteAddr { if c == ':' { return remoteAddr[:idx] } } return remoteAddr }
A estratégia que usei para limpar a porta foi iterar até encontrar o :
. A iteração do tipo "for-each" em uma string, ou vetor, ou slice, é feita usando o range value
, com o primeiro elemento retornado sendo o índice e o segundo elemento o valor em si. No caso acima, idx
é o índice e c
o caracter da string. Ao localizar o :
, pego um "slice" da string até aquele índice. Ou na ausência, retorna o que recebeu.
Para iterar um mapa usamos também o range mapValue
, mas a diferença é que, no lugar do primeiro elemento ser um índice, ele é a chave do mapa, e o segundo elemento é o valor associado àquela chave. Isso é bom para iterar nos headers:
for reqHeader, value := range r.Header { fmt.Println(reqHeader, value) if strings.ToLower(reqHeader) == "connection" || strings.ToLower(reqHeader) == "host" { continue } if strings.ToLower(reqHeader) == "te" { valueClean := []string{} for _, teValue := range value { if strings.ToLower(teValue) == "trailers" { continue } valueClean = append(valueClean, teValue) } if len(valueClean) == 0 { continue } value = valueClean } req.Header.Add(reqHeader, strings.Join(value, ", ")) } req.Header.Add("Forwarded", "for="+noPort(r.RemoteAddr))
A criação da requisição é bem direta, basicamente encaminhando o que veio do cliente:
reqPath := r.URL.Path relevantPath := reqPath[len(s.preffix):] req, err := http.NewRequest(r.Method, s.target+relevantPath, r.Body)
A única adaptação feita é a questão de que o relevantPath
é computado de acordo com o path da requisição e o prefixo do proxy.
Então, depois de toda essa informação extraída, mandamos a conexão e copiamos os headers da resposta (com exceção do Connection
):
rcvd, err := s.client.Do(req) if err != nil { fmt.Println("deu ruim lendo a resposta") return } for responseHeader, value := range rcvd.Header { fmt.Println(responseHeader, value) if strings.ToLower(responseHeader) == "connection" { continue } w.Header().Add(responseHeader, string(strings.Join(value, ", "))) } w.WriteHeader(rcvd.StatusCode) io.Copy(w, rcvd.Body)
O código inteiro fica assim:
package main import ( "fmt" "io" "log" "net/http" "strings" "time" ) type proxy struct { client *http.Client target string preffix string } func (s proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { reqPath := r.URL.Path fmt.Println(reqPath) relevantPath := reqPath[len(s.preffix):] fmt.Println(relevantPath) req, err := http.NewRequest(r.Method, s.target+relevantPath, r.Body) if err != nil { fmt.Println("deu ruim criando a req") return } for reqHeader, value := range r.Header { fmt.Println(reqHeader, value) if strings.ToLower(reqHeader) == "connection" || strings.ToLower(reqHeader) == "host" { continue } if strings.ToLower(reqHeader) == "te" { valueClean := []string{} for _, teValue := range value { if strings.ToLower(teValue) == "trailers" { continue } valueClean = append(valueClean, teValue) } if len(valueClean) == 0 { continue } value = valueClean } req.Header.Add(reqHeader, strings.Join(value, ", ")) } req.Header.Add("Forwarded", "for="+noPort(r.RemoteAddr)) fmt.Println(req.Header) fmt.Println() rcvd, err := s.client.Do(req) if err != nil { fmt.Println("deu ruim lendo a resposta") return } for responseHeader, value := range rcvd.Header { fmt.Println(responseHeader, value) if strings.ToLower(responseHeader) == "connection" { continue } w.Header().Add(responseHeader, string(strings.Join(value, ", "))) } w.WriteHeader(rcvd.StatusCode) io.Copy(w, rcvd.Body) } func noPort(remoteAddr string) string { for idx, c := range remoteAddr { if c == ':' { return remoteAddr[:idx] } } return remoteAddr } func main() { fmt.Println("olá") tr := &http.Transport{ MaxIdleConns: 10, IdleConnTimeout: 30 * time.Second, DisableCompression: true, } client := &http.Client{Transport: tr} proxyHandler := proxy{client, "http://localhost:4000/blog/", "/blog/"} http.Handle("/blog/", proxyHandler) log.Fatal(http.ListenAndServe(":8080", nil)) }
Top comments (2)
Que post foda!
Perdi o horario 😭 nao fui a primeira a comentar hahaah