mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
This PR merges code from `coder/aibridge` repository into `coder/coder`. It was split into 4 PRs for easier review but stacked PRs will need to be merged into this PR so all checks pass. * https://github.com/coder/coder/pull/24190 -> raw code copy (this PR, before merging PRs on top of it, it was just 1 commit: https://github.com/coder/coder/commit/70d33f33200c7e77df910957595715f81f9bec24) * https://github.com/coder/coder/pull/24570 -> update imports in `coder/coder` to use copied code * https://github.com/coder/coder/pull/24586 -> linter fixes and CI integration (also added README.md) * https://github.com/coder/coder/pull/24571 -> added exclude to scripts/check_emdash.sh check Original PR message (before PR squash): Moves coder/aibridge code into coder/coder repository. Omitted files: - `go.mod`, `go.sum`, `.gitignore`, `.github/workflows/ci.yml,` `Makefile`, `LICENSE`, `README.md` (modified README.md is added later) - `.github`, `example`, `buildinfo,` `scripts` directories Simple verification script (will list omitted files) ``` tmp=$(mktemp -d) echo "$tmp" git clone --depth=1 https://github.com/coder/aibridge "$tmp/aibridge" git clone --depth=1 --branch pb/aibridge-code-move https://github.com/coder/coder "$tmp/coder" diff -rq --exclude=.git "$tmp/aibridge" "$tmp/coder/aibridge" # rm -rf "$tmp" ```
304 lines
8.9 KiB
Go
304 lines
8.9 KiB
Go
package aibridge //nolint:testpackage // tests unexported newPassthroughRouter
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"maps"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"go.opentelemetry.io/otel"
|
|
|
|
"cdr.dev/slog/v3/sloggers/slogtest"
|
|
"github.com/coder/coder/v2/aibridge/internal/testutil"
|
|
)
|
|
|
|
var testTracer = otel.Tracer("bridge_test")
|
|
|
|
func TestPassthroughRoutes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
upstreamRespBody := "upstream response"
|
|
tests := []struct {
|
|
name string
|
|
baseURLPath string
|
|
reqPath string
|
|
reqHost string
|
|
reqRemoteAddr string
|
|
reqHeaders http.Header
|
|
expectRequestPath string
|
|
expectQuery string
|
|
expectHeaders http.Header
|
|
expectRespStatus int
|
|
expectRespBody string
|
|
}{
|
|
{
|
|
name: "passthrough_route_no_path",
|
|
reqPath: "/v1/conversations",
|
|
expectRequestPath: "/v1/conversations",
|
|
expectRespStatus: http.StatusOK,
|
|
expectRespBody: upstreamRespBody,
|
|
},
|
|
{
|
|
name: "base_URL_path_is_preserved_in_passthrough_routes",
|
|
baseURLPath: "/api/v2",
|
|
reqPath: "/v1/models",
|
|
expectRequestPath: "/api/v2/v1/models",
|
|
expectRespStatus: http.StatusOK,
|
|
expectRespBody: upstreamRespBody,
|
|
},
|
|
{
|
|
name: "passthrough_route_break_parse_base_url",
|
|
baseURLPath: "/%zz",
|
|
reqPath: "/v1/models/",
|
|
expectRespStatus: http.StatusBadGateway,
|
|
expectRespBody: "invalid provider base URL",
|
|
},
|
|
{
|
|
name: "passthrough_route_rejects_invalid_base_url_path",
|
|
baseURLPath: "/%25",
|
|
reqPath: "/v1/models",
|
|
expectRespStatus: http.StatusBadGateway,
|
|
expectRespBody: "invalid provider base URL",
|
|
},
|
|
{
|
|
name: "proxy_headers_are_set_and_forwarded_chain_is_appended",
|
|
reqPath: "/v1/models",
|
|
reqHost: "client.example.com",
|
|
reqRemoteAddr: "1.1.1.1:1111",
|
|
reqHeaders: http.Header{
|
|
"X-Forwarded-For": {"2.2.2.2, 3.3.3.3"},
|
|
},
|
|
expectRequestPath: "/v1/models",
|
|
expectRespStatus: http.StatusOK,
|
|
expectRespBody: upstreamRespBody,
|
|
expectHeaders: http.Header{
|
|
"Accept-Encoding": {"gzip"},
|
|
"User-Agent": {"aibridge"},
|
|
"X-Forwarded-For": {"2.2.2.2, 3.3.3.3, 1.1.1.1"},
|
|
"X-Forwarded-Host": {"client.example.com"},
|
|
"X-Forwarded-Proto": {"http"},
|
|
},
|
|
},
|
|
{
|
|
name: "query_string_is_preserved",
|
|
reqPath: "/v1/models?search=gpt&limit=10",
|
|
expectRequestPath: "/v1/models",
|
|
expectQuery: "search=gpt&limit=10",
|
|
expectRespStatus: http.StatusOK,
|
|
expectRespBody: upstreamRespBody,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
logger := slogtest.Make(t, nil)
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Equal(t, tc.expectRequestPath, r.URL.Path)
|
|
assert.Equal(t, tc.expectQuery, r.URL.RawQuery)
|
|
if tc.expectHeaders != nil {
|
|
assert.Equal(t, tc.expectHeaders, r.Header)
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(upstreamRespBody))
|
|
}))
|
|
t.Cleanup(upstream.Close)
|
|
|
|
prov := &testutil.MockProvider{
|
|
URL: upstream.URL + tc.baseURLPath,
|
|
}
|
|
|
|
handler := newPassthroughRouter(prov, logger, nil, testTracer)
|
|
|
|
req := httptest.NewRequest("", tc.reqPath, nil)
|
|
maps.Copy(req.Header, tc.reqHeaders)
|
|
req.Host = tc.reqHost
|
|
req.RemoteAddr = tc.reqRemoteAddr
|
|
resp := httptest.NewRecorder()
|
|
handler.ServeHTTP(resp, req)
|
|
|
|
assert.Equal(t, tc.expectRespStatus, resp.Code)
|
|
assert.Contains(t, resp.Body.String(), tc.expectRespBody)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRewritePassthroughRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
reqPath string
|
|
reqRemoteAddr string
|
|
reqHeaders http.Header
|
|
reqTLS bool
|
|
provider *testutil.MockProvider
|
|
expectURL string
|
|
expectHeaders http.Header
|
|
}{
|
|
{
|
|
name: "sets_upstream_url_and_forwarded_headers_from_client_peer",
|
|
reqPath: "http://client-host/chat?stream=true",
|
|
reqRemoteAddr: "1.1.1.1:1111",
|
|
provider: &testutil.MockProvider{URL: "https://upstream-host/base"},
|
|
expectURL: "https://upstream-host/base/chat?stream=true",
|
|
expectHeaders: http.Header{
|
|
"X-Forwarded-Host": {"client-host"},
|
|
"X-Forwarded-Proto": {"http"},
|
|
"X-Forwarded-For": {"1.1.1.1"},
|
|
"User-Agent": {"aibridge"},
|
|
},
|
|
},
|
|
{
|
|
name: "preserves_client_user_agent",
|
|
reqPath: "http://client-host/chat",
|
|
reqRemoteAddr: "1.1.1.1:1111",
|
|
reqHeaders: http.Header{"User-Agent": {"custom-agent/1.0"}},
|
|
provider: &testutil.MockProvider{URL: "https://upstream-host/base"},
|
|
expectURL: "https://upstream-host/base/chat",
|
|
expectHeaders: http.Header{
|
|
"X-Forwarded-Host": {"client-host"},
|
|
"X-Forwarded-Proto": {"http"},
|
|
"X-Forwarded-For": {"1.1.1.1"},
|
|
"User-Agent": {"custom-agent/1.0"},
|
|
},
|
|
},
|
|
{
|
|
name: "injects_auth_header",
|
|
reqPath: "http://client-host/chat",
|
|
reqRemoteAddr: "1.1.1.1:1111",
|
|
provider: &testutil.MockProvider{
|
|
URL: "https://upstream-host/base",
|
|
InjectAuthHeaderFunc: func(h *http.Header) {
|
|
h.Set("Authorization", "Bearer test-token")
|
|
},
|
|
},
|
|
expectURL: "https://upstream-host/base/chat",
|
|
expectHeaders: http.Header{
|
|
"X-Forwarded-Host": {"client-host"},
|
|
"X-Forwarded-Proto": {"http"},
|
|
"X-Forwarded-For": {"1.1.1.1"},
|
|
"User-Agent": {"aibridge"},
|
|
"Authorization": {"Bearer test-token"},
|
|
},
|
|
},
|
|
{
|
|
name: "appends_remote_addr_to_existing_forwarded_for_chain",
|
|
reqPath: "http://client-host/chat",
|
|
reqRemoteAddr: "1.1.1.1:1111",
|
|
reqHeaders: http.Header{
|
|
"X-Forwarded-For": {"2.2.2.2, 3.3.3.3"},
|
|
},
|
|
provider: &testutil.MockProvider{URL: "https://upstream-host/base"},
|
|
expectURL: "https://upstream-host/base/chat",
|
|
expectHeaders: http.Header{
|
|
"X-Forwarded-Host": {"client-host"},
|
|
"X-Forwarded-Proto": {"http"},
|
|
"X-Forwarded-For": {"2.2.2.2, 3.3.3.3, 1.1.1.1"},
|
|
"User-Agent": {"aibridge"},
|
|
},
|
|
},
|
|
{
|
|
name: "tls_request_sets_forwarded_proto_to_https",
|
|
reqPath: "http://client-host/chat",
|
|
reqRemoteAddr: "1.1.1.1:1111",
|
|
reqTLS: true,
|
|
provider: &testutil.MockProvider{URL: "https://upstream-host/base"},
|
|
expectURL: "https://upstream-host/base/chat",
|
|
expectHeaders: http.Header{
|
|
"X-Forwarded-Host": {"client-host"},
|
|
"X-Forwarded-Proto": {"https"},
|
|
"X-Forwarded-For": {"1.1.1.1"},
|
|
"User-Agent": {"aibridge"},
|
|
},
|
|
},
|
|
{
|
|
// This is an edge case where whole `X-Forwarded-For` header
|
|
// is dropped if last hop (remote addr) is not parseable.
|
|
// This is how library handles this case and is not directly
|
|
// related to our code. Added it to verify that we
|
|
// don't accidentally break this behavior.
|
|
name: "omits_forwarded_for_when_remote_addr_is_not_parseable",
|
|
reqPath: "http://client-host/chat",
|
|
reqRemoteAddr: "not-a-socket-address",
|
|
reqHeaders: http.Header{
|
|
"X-Forwarded-For": {"1.1.1.1"},
|
|
},
|
|
provider: &testutil.MockProvider{URL: "https://upstream-host/base"},
|
|
expectURL: "https://upstream-host/base/chat",
|
|
expectHeaders: http.Header{
|
|
"X-Forwarded-Host": {"client-host"},
|
|
"X-Forwarded-Proto": {"http"},
|
|
"User-Agent": {"aibridge"},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
r := httptest.NewRequest(http.MethodGet, tc.reqPath, nil)
|
|
maps.Copy(r.Header, tc.reqHeaders)
|
|
r.RemoteAddr = tc.reqRemoteAddr
|
|
if tc.reqTLS {
|
|
r.TLS = &tls.ConnectionState{}
|
|
}
|
|
provBaseURL, err := url.Parse(tc.provider.URL)
|
|
assert.NoError(t, err)
|
|
|
|
pr := &httputil.ProxyRequest{
|
|
In: r,
|
|
Out: r.Clone(r.Context()),
|
|
}
|
|
|
|
rewritePassthroughRequest(pr, provBaseURL, tc.provider)
|
|
|
|
assert.Equal(t, tc.expectURL, pr.Out.URL.String())
|
|
assert.Equal(t, "", pr.Out.Host)
|
|
assert.Equal(t, tc.expectHeaders, pr.Out.Header)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPassthroughRouterReusesProxyInstance(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var newConnections atomic.Int32
|
|
upstream := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
}))
|
|
upstream.Config.ConnState = func(_ net.Conn, state http.ConnState) {
|
|
if state == http.StateNew {
|
|
newConnections.Add(1)
|
|
}
|
|
}
|
|
upstream.Start()
|
|
t.Cleanup(upstream.Close)
|
|
|
|
logger := slogtest.Make(t, nil)
|
|
prov := &testutil.MockProvider{URL: upstream.URL}
|
|
handler := newPassthroughRouter(prov, logger, nil, testTracer)
|
|
|
|
for i := range 2 {
|
|
req := httptest.NewRequest(http.MethodGet, "http://proxy.example.test/v1/models", nil)
|
|
resp := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(resp, req)
|
|
|
|
assert.Equalf(t, http.StatusOK, resp.Code, "request %d", i+1)
|
|
assert.Equal(t, "ok", resp.Body.String())
|
|
}
|
|
|
|
assert.EqualValues(t, 1, newConnections.Load())
|
|
}
|