Fazendo um proxy reverso em Go

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, ou QUERY, 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úblico ServeHTTP adequado para a interface http.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

Mar 19, 2025 - 02:07
 0
Fazendo um proxy reverso em Go

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, ou QUERY, 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úblico ServeHTTP adequado para a interface http.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:

Não pode usar fooHandler2 porque não implementa o método ServeHTTP (ServeHTTP é um campo, não um método)

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:

Resposta 'Hello,

Porém, uma pequena mudança torna isso inacessível:

O Safari não pode abrir a página

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 
leitura parcial 
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. The Header map also is the mechanism with which Handler implementations can set HTTP trailers.

Changing the header map after a call to ResponseWriter.WriteHeader (or ResponseWriter.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))
}