T5DE's local proxy for IMVU Classic
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]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-29 00:03:51 -07:00
commit 2551888ed6
19 files changed
+1498

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
package proxy
import (
"bytes"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"sync"
"time"
"git.t5de.dev/t5de/t5de-proxy/internal/imq"
)
// serveConnect connects the client's plaintext stream to host:port over TLS.
func (s *Server) serveConnect(w http.ResponseWriter, r *http.Request) {
id := s.requests.Add(1)
start := time.Now()
target := r.Host
host, _, err := net.SplitHostPort(target)
if err != nil {
http.Error(w, "t5de-proxy: expected CONNECT host:port", http.StatusBadRequest)
return
}
config := &tls.Config{}
if s.TLSConfig != nil {
config = s.TLSConfig.Clone()
}
config.ServerName = host
dial := s.Dial
if dial == nil {
dial = (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext
}
raw, err := dial(r.Context(), "tcp", target)
var upstream *tls.Conn
if err == nil {
upstream = tls.Client(raw, config)
if err = upstream.HandshakeContext(r.Context()); err != nil {
raw.Close()
}
}
if err != nil {
s.logf("#%d CONNECT %s failed after %s: %v", id, target, since(start), err)
http.Error(w, "t5de-proxy: "+err.Error(), http.StatusBadGateway)
return
}
defer upstream.Close()
conn, buffered, err := http.NewResponseController(w).Hijack()
if err != nil {
s.logf("#%d CONNECT %s: %v", id, target, err)
return
}
defer conn.Close()
if _, err := io.WriteString(conn, "HTTP/1.1 200 Connection established\r\n\r\n"); err != nil {
return
}
s.logf("#%d CONNECT %s open (%s)", id, target, since(start))
// Whatever the client sent after its CONNECT and the server already read is the stream's start.
var client io.Reader = conn
if n := buffered.Reader.Buffered(); n > 0 {
head, _ := buffered.Reader.Peek(n)
client = io.MultiReader(bytes.NewReader(bytes.Clone(head)), conn)
}
up := &stream{s: s, id: id, name: "c2g"}
down := &stream{s: s, id: id, name: "g2c"}
// IMQ is a session: when either side closes, it's over, so both connections close.
var once sync.Once
closeBoth := func() {
once.Do(func() {
conn.Close()
upstream.Close()
})
}
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
defer closeBoth()
up.copy(upstream, client)
}()
go func() {
defer wg.Done()
defer closeBoth()
down.copy(conn, upstream)
}()
wg.Wait()
s.logf("#%d CONNECT %s closed after %s: %s, %s", id, target, since(start), up.summary(), down.summary())
}
// stream is one direction of a CONNECT, counting bytes and IMQ frames as they pass.
type stream struct {
s *Server
id int64
name string
bytes int64
frames int
other bool // not IMQ framing
reader imq.Reader
}
func (st *stream) copy(dst io.Writer, src io.Reader) {
buf := make([]byte, 32<<10)
for {
n, err := src.Read(buf)
if n > 0 {
st.observe(buf[:n])
if _, werr := dst.Write(buf[:n]); werr != nil {
return
}
}
if err != nil {
return
}
}
}
func (st *stream) observe(data []byte) {
st.bytes += int64(len(data))
if st.other {
return
}
frames, err := st.reader.Write(data)
st.frames += len(frames)
if st.s.Trace {
for _, f := range frames {
st.s.logf("#%d %s %s %dB", st.id, st.name, imq.TypeName(f.Type), f.Size)
}
}
if err != nil {
st.other = true
st.s.logf("#%d %s isn't IMQ framing; passing it through", st.id, st.name)
}
}
func (st *stream) summary() string {
if st.other {
return fmt.Sprintf("%s %dB", st.name, st.bytes)
}
return fmt.Sprintf("%s %dB in %d frames", st.name, st.bytes, st.frames)
}
+76
View File
@@ -0,0 +1,76 @@
package proxy
import (
"net/url"
"regexp"
"strings"
)
// upgradeHosts are IMVU hosts the client reaches over plain HTTP that serve the same over HTTPS: the proxy
// fetches them over HTTPS, so the client's account headers (X-imvu-auth, cookies) and what it browses don't cross
// the network in the clear. Checked against the client's real requests (docs/hosts.md).
var upgradeHosts = map[string]bool{
"www.imvu.com": true,
"client-dynamic.imvu.com": true,
"secure.imvu.com": true,
"api.imvu.com": true,
"userimages.imvu.com": true,
"userimages-akm.imvu.com": true,
"static-akm.imvu.com": true,
"static.imvu.com": true,
"webasset-akm.imvu.com": true,
"chat.imvu.com": true,
"chatnow.imvu.com": true,
}
// numberedUpgradeHosts are the numbered image hosts (userimages01-akm.imvu.com, ...), upgraded like the others.
var numberedUpgradeHosts = regexp.MustCompile(`^userimages[0-9]+-akm\.imvu\.com$`)
// Upgrade reports whether an http URL is fetched over HTTPS instead.
func Upgrade(u *url.URL) bool {
if u.Scheme != "http" || u.Port() != "" {
return false
}
host := strings.ToLower(u.Hostname())
return upgradeHosts[host] || numberedUpgradeHosts.MatchString(host)
}
// blocked are IMVU's crash, error, log and metrics reporting endpoints: by host, or by path on hosts it shares
// with the API. T5DE's `reporting-blocklist` patch blocks the same in the client; the proxy blocks them again
// for anything that reaches it.
var (
blockedHosts = []string{"bugs.imvu.com"}
blockedPaths = []string{
"/api/drv.php",
"/api/increment_counter.php",
"/api/record_gauge.php",
"/catalog/web_submit_logs.php",
"/catalog/imvu_monitor_submit.php",
}
// blockedPrefixes are blocked on api.imvu.com only.
blockedAPIPrefixes = []string{"/events"}
)
// Blocked reports whether a request to u is refused.
func Blocked(u *url.URL) bool {
host := strings.ToLower(u.Hostname())
for _, h := range blockedHosts {
if host == h {
return true
}
}
path := strings.ToLower(u.EscapedPath())
for _, p := range blockedPaths {
if path == p {
return true
}
}
if host == "api.imvu.com" {
for _, p := range blockedAPIPrefixes {
if path == p || strings.HasPrefix(path, p+"/") {
return true
}
}
}
return false
}
+263
View File
@@ -0,0 +1,263 @@
// 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()
}
+342
View File
@@ -0,0 +1,342 @@
package proxy
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
)
const token = "secret"
var auth = "Basic " + base64.StdEncoding.EncodeToString([]byte("t5de:"+token))
// send makes a raw request to the proxy, as the client does, and returns the response.
func send(t *testing.T, proxy *httptest.Server, raw string) *http.Response {
t.Helper()
conn, err := net.Dial("tcp", proxy.Listener.Addr().String())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { conn.Close() })
if _, err := io.WriteString(conn, raw); err != nil {
t.Fatal(err)
}
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { resp.Body.Close() })
return resp
}
func body(t *testing.T, resp *http.Response) string {
t.Helper()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// logBuffer collects the proxy's log, written from its handlers' goroutines.
type logBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *logBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *logBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
func newProxy(t *testing.T, s *Server) (*httptest.Server, *logBuffer) {
logs := &logBuffer{}
s.Token = token
s.Log = log.New(logs, "", 0)
p := httptest.NewServer(s)
t.Cleanup(p.Close)
return p, logs
}
func TestRefusesRequestsWithoutTheToken(t *testing.T) {
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("an unauthorized request reached the server")
}))
defer origin.Close()
p, _ := newProxy(t, &Server{})
for _, header := range []string{"", "Proxy-Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte("t5de:wrong")) + "\r\n"} {
resp := send(t, p, "GET "+origin.URL+"/ HTTP/1.1\r\nHost: x\r\n"+header+"\r\n")
if resp.StatusCode != http.StatusProxyAuthRequired {
t.Fatalf("got %d, want 407", resp.StatusCode)
}
}
}
func TestForwardsAsTheClientAsked(t *testing.T) {
var got *http.Request
var gotBody string
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Set-Cookie", "a=b")
w.Header().Set("Location", "https://example.invalid/next")
w.WriteHeader(http.StatusFound)
io.WriteString(w, "compressed bytes")
}))
defer origin.Close()
p, logs := newProxy(t, &Server{})
resp := send(t, p, "POST "+origin.URL+"/api/xmlrpc/client.php?sid=1 HTTP/1.1\r\nHost: ignored\r\n"+
"Proxy-Authorization: "+auth+"\r\nX-imvu-auth: abc\r\nAccept-Encoding: gzip\r\nConnection: close\r\n"+
"Content-Length: 5\r\n\r\nhello")
// The response comes back as the server sent it: the redirect not followed, the body not decompressed.
if resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != "https://example.invalid/next" ||
resp.Header.Get("Content-Encoding") != "gzip" || resp.Header.Get("Set-Cookie") != "a=b" {
t.Fatalf("response: %d %v", resp.StatusCode, resp.Header)
}
if b := body(t, resp); b != "compressed bytes" {
t.Fatalf("body %q", b)
}
if got.URL.RequestURI() != "/api/xmlrpc/client.php?sid=1" || gotBody != "hello" || got.Header.Get("X-imvu-auth") != "abc" ||
got.Header.Get("Accept-Encoding") != "gzip" {
t.Fatalf("request: %s %v %q", got.URL, got.Header, gotBody)
}
// Neither the proxy's token nor a user agent the client didn't send reach the server.
if got.Header.Get("Proxy-Authorization") != "" || got.Header.Get("User-Agent") != "" {
t.Fatalf("request headers: %v", got.Header)
}
if got.Host != strings.TrimPrefix(origin.URL, "http://") {
t.Fatalf("Host %q", got.Host)
}
// The log leaves out queries.
if !strings.Contains(logs.String(), "/api/xmlrpc/client.php?… 302") || strings.Contains(logs.String(), "sid=1") {
t.Fatalf("log: %s", logs)
}
}
func TestForwardsHTTPS(t *testing.T) {
origin := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "secure "+r.URL.Path)
}))
defer origin.Close()
p, _ := newProxy(t, &Server{Transport: origin.Client().Transport})
resp := send(t, p, "GET "+origin.URL+"/x HTTP/1.1\r\nHost: x\r\nProxy-Authorization: "+auth+"\r\n\r\n")
if b := body(t, resp); resp.StatusCode != 200 || b != "secure /x" {
t.Fatalf("%d %q", resp.StatusCode, b)
}
}
// recorder is a transport that records requests and answers them itself.
type recorder struct {
mu sync.Mutex
urls []string
fail func(*http.Request) error
}
func (r *recorder) RoundTrip(req *http.Request) (*http.Response, error) {
r.mu.Lock()
r.urls = append(r.urls, req.URL.String())
r.mu.Unlock()
if r.fail != nil {
if err := r.fail(req); err != nil {
return nil, err
}
}
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader("ok")), Request: req}, nil
}
func TestUpgradesIMVUHostsToHTTPS(t *testing.T) {
rec := &recorder{}
p, _ := newProxy(t, &Server{Transport: rec})
for _, u := range []string{"http://client-dynamic.imvu.com/api/xmlrpc/client.php", "http://userimages03-akm.imvu.com/a.png", "http://example.com/a", "http://www.imvu.com:8080/a", "http://product.virtual.imvu.com/a"} {
body(t, send(t, p, "GET "+u+" HTTP/1.1\r\nHost: x\r\nProxy-Authorization: "+auth+"\r\n\r\n"))
}
want := []string{"https://client-dynamic.imvu.com/api/xmlrpc/client.php", "https://userimages03-akm.imvu.com/a.png", "http://example.com/a", "http://www.imvu.com:8080/a", "http://product.virtual.imvu.com/a"}
if fmt.Sprint(rec.urls) != fmt.Sprint(want) {
t.Fatalf("got %v, want %v", rec.urls, want)
}
}
func TestFallsBackToHTTPWhenHTTPSFails(t *testing.T) {
rec := &recorder{fail: func(r *http.Request) error {
if r.URL.Scheme == "https" {
return errors.New("tls: handshake failure")
}
return nil
}}
p, _ := newProxy(t, &Server{Transport: rec})
resp := send(t, p, "GET http://userimages.imvu.com/a.png HTTP/1.1\r\nHost: x\r\nProxy-Authorization: "+auth+"\r\n\r\n")
if b := body(t, resp); resp.StatusCode != 200 || b != "ok" {
t.Fatalf("%d %q", resp.StatusCode, b)
}
want := []string{"https://userimages.imvu.com/a.png", "http://userimages.imvu.com/a.png"}
if fmt.Sprint(rec.urls) != fmt.Sprint(want) {
t.Fatalf("got %v, want %v", rec.urls, want)
}
}
func TestBlocksReportingEndpoints(t *testing.T) {
rec := &recorder{}
p, logs := newProxy(t, &Server{Transport: rec})
for _, u := range []string{"http://bugs.imvu.com/x", "https://www.imvu.com/catalog/web_submit_logs.php", "https://api.imvu.com/events/1"} {
if resp := send(t, p, "POST "+u+" HTTP/1.1\r\nHost: x\r\nProxy-Authorization: "+auth+"\r\nContent-Length: 0\r\n\r\n"); resp.StatusCode != http.StatusForbidden {
t.Fatalf("%s: %d", u, resp.StatusCode)
}
}
if len(rec.urls) != 0 || strings.Count(logs.String(), "blocked") != 3 {
t.Fatalf("forwarded %v; log %s", rec.urls, logs)
}
for _, u := range []string{"https://api.imvu.com/eventsx", "https://api.imvu.com/user/1"} {
parsed, _ := url.Parse(u)
if Blocked(parsed) {
t.Fatalf("%s blocked", u)
}
}
}
func TestRejectsOriginFormRequests(t *testing.T) {
p, _ := newProxy(t, &Server{Transport: &recorder{}})
if resp := send(t, p, "GET /a HTTP/1.1\r\nHost: www.imvu.com\r\nProxy-Authorization: "+auth+"\r\n\r\n"); resp.StatusCode != http.StatusBadRequest {
t.Fatalf("got %d", resp.StatusCode)
}
}
// imqFrame encodes an IMQ frame.
func imqFrame(typ uint64, data string) []byte {
b := []byte{0x08, byte(typ) | 0x80, byte(typ >> 7), 0x12, byte(len(data))}
return append(b, data...)
}
func TestConnectSpeaksTLSUpstream(t *testing.T) {
// A TLS echo server stands in for IMQ's gateway.
gateway := httptest.NewUnstartedServer(nil)
gateway.StartTLS()
gateway.Close() // only for its certificate
cert := gateway.TLS.Certificates[0]
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}})
if err != nil {
t.Fatal(err)
}
defer ln.Close()
go func() {
c, err := ln.Accept()
if err != nil {
return
}
defer c.Close()
io.Copy(c, c)
}()
roots := gateway.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs
p, logs := newProxy(t, &Server{TLSConfig: &tls.Config{RootCAs: roots}, Trace: true,
Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
if addr != "example.com:443" {
return nil, fmt.Errorf("dialed %s", addr)
}
return net.Dial(network, ln.Addr().String())
}})
conn, err := net.Dial("tcp", p.Listener.Addr().String())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
// The test certificate is for example.com. The client's first frame comes with its CONNECT.
hello := imqFrame(1302, "connect")
fmt.Fprintf(conn, "CONNECT example.com:443 HTTP/1.1\r\nProxy-authorization: %s\r\n\r\n%s", auth, hello)
r := bufio.NewReader(conn)
status, err := r.ReadString('\n')
if err != nil {
t.Fatalf("%v; log %s", err, logs)
}
if !strings.HasPrefix(status, "HTTP/1.1 200") {
t.Fatalf("status %q; log %s", status, logs)
}
for line, _ := r.ReadString('\n'); line != "\r\n"; line, _ = r.ReadString('\n') {
}
echo := make([]byte, len(hello))
if _, err := io.ReadFull(r, echo); err != nil || !bytes.Equal(echo, hello) {
t.Fatalf("echo %q %v", echo, err)
}
conn.Close()
// The log names the frames each way.
for i := 0; i < 200 && !strings.Contains(logs.String(), "closed"); i++ {
time.Sleep(10 * time.Millisecond)
}
if !strings.Contains(logs.String(), "c2g C2gConnect 7B") || !strings.Contains(logs.String(), "g2c C2gConnect 7B") {
t.Fatalf("log: %s", logs)
}
}
func TestReportsUnreachableServers(t *testing.T) {
rec := &recorder{fail: func(*http.Request) error { return errors.New("dial tcp: no such host") }}
p, _ := newProxy(t, &Server{Transport: rec})
resp := send(t, p, "GET https://nowhere.imvu.com/ HTTP/1.1\r\nHost: x\r\nProxy-Authorization: "+auth+"\r\n\r\n")
if resp.StatusCode != http.StatusBadGateway || !strings.Contains(resp.Header.Get(ErrorHeader), "no such host") {
t.Fatalf("%d %v", resp.StatusCode, resp.Header)
}
}
func TestBuffersBodiesToSendAgain(t *testing.T) {
// Go's transport sends a request again when the server closed a pooled connection as it went out, if it can
// get the body again (GetBody); without it, it fails with "cannot rewind body after connection loss".
in := httptest.NewRequest("POST", "http://x/", strings.NewReader("hello"))
out := in.Clone(in.Context())
if ok, err := bufferBody(out, in); !ok || err != nil || out.GetBody == nil || out.ContentLength != 5 {
t.Fatalf("%v %v %d", ok, err, out.ContentLength)
}
for i := 0; i < 2; i++ {
body, _ := out.GetBody()
if b, _ := io.ReadAll(body); string(b) != "hello" {
t.Fatalf("body %q", b)
}
}
// Bodies over maxBuffered are streamed whole, and can't be sent again.
big := strings.Repeat("x", maxBuffered+10)
in = httptest.NewRequest("POST", "http://x/", strings.NewReader(big))
out = in.Clone(in.Context())
if ok, err := bufferBody(out, in); ok || err != nil || out.GetBody != nil {
t.Fatalf("%v %v", ok, err)
}
if b, _ := io.ReadAll(out.Body); len(b) != len(big) {
t.Fatalf("streamed %d bytes", len(b))
}
}
func TestFallsBackToHTTPWithTheBody(t *testing.T) {
var got []string
rec := &recorder{fail: func(r *http.Request) error {
b, _ := io.ReadAll(r.Body)
got = append(got, r.URL.Scheme+" "+string(b))
if r.URL.Scheme == "https" {
return errors.New("tls: handshake failure")
}
return nil
}}
p, _ := newProxy(t, &Server{Transport: rec})
resp := send(t, p, "POST http://client-dynamic.imvu.com/api/xmlrpc/client.php HTTP/1.1\r\nHost: x\r\nProxy-Authorization: "+auth+"\r\nContent-Length: 4\r\n\r\nbody")
if resp.StatusCode != 200 || fmt.Sprint(got) != "[https body http body]" {
t.Fatalf("%d %q", resp.StatusCode, got)
}
}