CI / build (push) Successful in 45s
The client sends it every HTTP request and its IMQ connection; it makes them with current TLS and certificates, upgrades IMVU's plain-HTTP hosts to HTTPS, and refuses IMVU's reporting endpoints. CI tests it and publishes t5de-proxy.exe as nightlies and tagged releases. Co-Authored-By: Claude Opus 5.5 <[email protected]>
264 lines
8.4 KiB
Go
264 lines
8.4 KiB
Go
// Package proxy is T5DE's local proxy for IMVU Classic: the client sends it every HTTP request (Python's and its
|
|
// embedded browser's, which IMVU routes through Python) and its IMQ connection, and the proxy makes them with
|
|
// Go's networking: current TLS and root certificates, HTTP/2 and pooled connections, HTTPS for IMVU hosts the
|
|
// client reaches over plain HTTP, and reporting endpoints refused.
|
|
//
|
|
// Protocol (only the client's own requests are served: every one carries the token the proxy gave it):
|
|
//
|
|
// - HTTP requests in absolute form, for http and https URLs alike, over plain HTTP on 127.0.0.1
|
|
// ("GET https://api.imvu.com/... HTTP/1.1"), with "Proxy-Authorization: Basic base64(t5de:<token>)".
|
|
// The proxy makes the request and returns the response as is: no redirects followed, no decompression.
|
|
// - CONNECT host:port with the same header: the client then speaks plaintext, and the proxy connects to
|
|
// host:port over TLS. The client uses this for IMQ.
|
|
package proxy
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/subtle"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// ErrorHeader is set on the proxy's own 502 responses, when it couldn't reach the server, to the error.
|
|
const ErrorHeader = "X-T5de-Proxy-Error"
|
|
|
|
// Server is the proxy, an http.Handler.
|
|
type Server struct {
|
|
// Token authenticates the client's requests.
|
|
Token string
|
|
// Log receives a line per request and connection.
|
|
Log *log.Logger
|
|
// Trace adds full URLs (with queries) and every IMQ frame to the log.
|
|
Trace bool
|
|
// Transport makes HTTP requests; nil is NewTransport().
|
|
Transport http.RoundTripper
|
|
// TLSConfig is the base TLS configuration of CONNECT's upstream connections (tests set RootCAs); nil is the
|
|
// default, with the system's roots.
|
|
TLSConfig *tls.Config
|
|
// Dial makes CONNECT's upstream connections (tests point it at local servers); nil is a net.Dialer.
|
|
Dial func(ctx context.Context, network, addr string) (net.Conn, error)
|
|
|
|
requests atomic.Int64
|
|
}
|
|
|
|
// NewTransport is the HTTP transport the proxy uses by default: HTTP/2 where servers offer it, pooled
|
|
// connections, and responses passed through compressed.
|
|
func NewTransport() *http.Transport {
|
|
return &http.Transport{
|
|
DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
|
ForceAttemptHTTP2: true,
|
|
MaxIdleConns: 100,
|
|
MaxIdleConnsPerHost: 16,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
TLSHandshakeTimeout: 15 * time.Second,
|
|
ExpectContinueTimeout: time.Second,
|
|
// The client asks for gzip itself and decompresses it; leave Accept-Encoding and bodies alone.
|
|
DisableCompression: true,
|
|
}
|
|
}
|
|
|
|
func (s *Server) transport() http.RoundTripper {
|
|
if s.Transport == nil {
|
|
s.Transport = NewTransport()
|
|
}
|
|
return s.Transport
|
|
}
|
|
|
|
func (s *Server) logf(format string, args ...any) {
|
|
if s.Log != nil {
|
|
s.Log.Printf(format, args...)
|
|
}
|
|
}
|
|
|
|
// authorized checks the request's Proxy-Authorization against the token.
|
|
func (s *Server) authorized(r *http.Request) bool {
|
|
auth := r.Header.Get("Proxy-Authorization")
|
|
scheme, encoded, ok := strings.Cut(auth, " ")
|
|
if !ok || !strings.EqualFold(scheme, "Basic") {
|
|
return false
|
|
}
|
|
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
user, password, _ := strings.Cut(string(decoded), ":")
|
|
return s.Token != "" && user == "t5de" && subtle.ConstantTimeCompare([]byte(password), []byte(s.Token)) == 1
|
|
}
|
|
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if !s.authorized(r) {
|
|
w.Header().Set("Proxy-Authenticate", `Basic realm="t5de-proxy"`)
|
|
http.Error(w, "t5de-proxy: not authorized", http.StatusProxyAuthRequired)
|
|
return
|
|
}
|
|
if r.Method == http.MethodConnect {
|
|
s.serveConnect(w, r)
|
|
return
|
|
}
|
|
if !r.URL.IsAbs() || (r.URL.Scheme != "http" && r.URL.Scheme != "https") || r.URL.Host == "" {
|
|
http.Error(w, "t5de-proxy: expected an absolute http or https URL", http.StatusBadRequest)
|
|
return
|
|
}
|
|
s.forward(w, r)
|
|
}
|
|
|
|
// hopHeaders are a connection's own headers, not passed on (RFC 9110 7.6.1).
|
|
var hopHeaders = []string{
|
|
"Connection", "Proxy-Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization",
|
|
"Te", "Trailer", "Transfer-Encoding", "Upgrade",
|
|
}
|
|
|
|
func removeHopHeaders(h http.Header) {
|
|
for _, field := range h.Values("Connection") {
|
|
for _, name := range strings.Split(field, ",") {
|
|
if name = strings.TrimSpace(name); name != "" {
|
|
h.Del(name)
|
|
}
|
|
}
|
|
}
|
|
for _, name := range hopHeaders {
|
|
h.Del(name)
|
|
}
|
|
}
|
|
|
|
// logURL is u as logged: without its query (which can hold session values) unless tracing.
|
|
func (s *Server) logURL(u *url.URL) string {
|
|
if s.Trace || u.RawQuery == "" {
|
|
return u.String()
|
|
}
|
|
v := *u
|
|
v.RawQuery = ""
|
|
return v.String() + "?…"
|
|
}
|
|
|
|
func (s *Server) forward(w http.ResponseWriter, r *http.Request) {
|
|
id := s.requests.Add(1)
|
|
start := time.Now()
|
|
target := *r.URL
|
|
if Blocked(&target) {
|
|
s.logf("#%d %s %s blocked", id, r.Method, s.logURL(&target))
|
|
http.Error(w, "t5de: blocked request", http.StatusForbidden)
|
|
return
|
|
}
|
|
upgraded := Upgrade(&target)
|
|
if upgraded {
|
|
target.Scheme = "https"
|
|
}
|
|
|
|
out := r.Clone(r.Context())
|
|
out.RequestURI = ""
|
|
out.URL = &target
|
|
out.Host = ""
|
|
removeHopHeaders(out.Header)
|
|
if _, ok := out.Header["User-Agent"]; !ok {
|
|
out.Header.Set("User-Agent", "") // send none, as the client didn't, rather than Go's
|
|
}
|
|
replayable, err := bufferBody(out, r)
|
|
if err != nil {
|
|
s.logf("#%d %s %s: reading the request: %v", id, r.Method, s.logURL(&target), err)
|
|
http.Error(w, "t5de-proxy: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
resp, err := s.transport().RoundTrip(out)
|
|
if err != nil && upgraded && replayable && r.Context().Err() == nil {
|
|
// The host didn't take HTTPS this time: fetch it as the client asked.
|
|
s.logf("#%d %s %s over HTTPS failed (%v); retrying over HTTP", id, r.Method, s.logURL(&target), err)
|
|
retry := out.Clone(r.Context())
|
|
retry.URL = r.URL
|
|
if out.GetBody != nil {
|
|
retry.Body, _ = out.GetBody()
|
|
}
|
|
upgraded = false
|
|
resp, err = s.transport().RoundTrip(retry)
|
|
}
|
|
if err != nil {
|
|
s.logf("#%d %s %s failed after %s: %v", id, r.Method, s.logURL(&target), since(start), err)
|
|
// The header tells the client this is the proxy's error, not the server's: it raises it as a network
|
|
// error, as it would have connecting directly.
|
|
w.Header().Set(ErrorHeader, err.Error())
|
|
http.Error(w, "t5de-proxy: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
removeHopHeaders(resp.Header)
|
|
for name, values := range resp.Header {
|
|
w.Header()[name] = values
|
|
}
|
|
w.WriteHeader(resp.StatusCode)
|
|
n, err := copyFlushing(w, resp.Body)
|
|
note := ""
|
|
if upgraded {
|
|
note = " (https)"
|
|
}
|
|
if err != nil && !errors.Is(err, context.Canceled) {
|
|
note += " body error: " + err.Error()
|
|
}
|
|
s.logf("#%d %s %s %d %dB %s%s", id, r.Method, s.logURL(r.URL), resp.StatusCode, n, since(start), note)
|
|
}
|
|
|
|
// maxBuffered bounds the request bodies the proxy holds in memory; larger ones (uploads) are streamed.
|
|
const maxBuffered = 32 << 20
|
|
|
|
// bufferBody reads a request's body into memory, so it can be sent again: by the transport, when a pooled
|
|
// connection turns out to have been closed by the server, and over HTTP after a failed upgrade. It reports
|
|
// whether the request can be sent again.
|
|
func bufferBody(out, in *http.Request) (bool, error) {
|
|
if in.Body == nil || in.ContentLength == 0 {
|
|
out.Body, out.GetBody, out.ContentLength = nil, nil, 0
|
|
return true, nil
|
|
}
|
|
head, err := io.ReadAll(io.LimitReader(in.Body, maxBuffered+1))
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if len(head) > maxBuffered {
|
|
out.Body = io.NopCloser(io.MultiReader(bytes.NewReader(head), in.Body))
|
|
return false, nil
|
|
}
|
|
out.ContentLength = int64(len(head))
|
|
out.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(head)), nil }
|
|
out.Body, _ = out.GetBody()
|
|
return true, nil
|
|
}
|
|
|
|
// copyFlushing copies a response body to the client, flushing as it arrives (long polls and streams included).
|
|
func copyFlushing(w http.ResponseWriter, body io.Reader) (int64, error) {
|
|
rc := http.NewResponseController(w)
|
|
buf := make([]byte, 32<<10)
|
|
var total int64
|
|
for {
|
|
n, err := body.Read(buf)
|
|
if n > 0 {
|
|
if _, werr := w.Write(buf[:n]); werr != nil {
|
|
return total, werr
|
|
}
|
|
total += int64(n)
|
|
if err == nil {
|
|
_ = rc.Flush()
|
|
}
|
|
}
|
|
if err == io.EOF {
|
|
return total, nil
|
|
}
|
|
if err != nil {
|
|
return total, err
|
|
}
|
|
}
|
|
}
|
|
|
|
func since(t time.Time) string {
|
|
return time.Since(t).Round(time.Millisecond).String()
|
|
}
|