diff --git a/coderd/tailnet.go b/coderd/tailnet.go index bfbfcabdeb..c37f583da2 100644 --- a/coderd/tailnet.go +++ b/coderd/tailnet.go @@ -3,6 +3,7 @@ package coderd import ( "bufio" "context" + "crypto/tls" "net" "net/http" "net/http/httputil" @@ -70,6 +71,17 @@ func NewServerTailnet( tn.transport.DialContext = tn.dialContext tn.transport.MaxIdleConnsPerHost = 10 tn.transport.MaxIdleConns = 0 + // We intentionally don't verify the certificate chain here. + // The connection to the workspace is already established and most + // apps are already going to be accessed over plain HTTP, this config + // simply allows apps being run over HTTPS to be accessed without error -- + // many of which may be using self-signed certs. + tn.transport.TLSClientConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + //nolint:gosec + InsecureSkipVerify: true, + } + agentConn, err := getMultiAgent(ctx) if err != nil { return nil, xerrors.Errorf("get initial multi agent: %w", err) diff --git a/coderd/tailnet_test.go b/coderd/tailnet_test.go index d634139193..46408179a2 100644 --- a/coderd/tailnet_test.go +++ b/coderd/tailnet_test.go @@ -62,66 +62,105 @@ func TestServerTailnet_AgentConn_Legacy(t *testing.T) { assert.True(t, conn.AwaitReachable(ctx)) } -func TestServerTailnet_ReverseProxy_OK(t *testing.T) { +func TestServerTailnet_ReverseProxy(t *testing.T) { t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + t.Run("OK", func(t *testing.T) { + t.Parallel() - // Force a connection through wsconncache using the legacy hardcoded ip. - agentID, _, serverTailnet := setupAgent(t, nil) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() - u, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", codersdk.WorkspaceAgentHTTPAPIServerPort)) - require.NoError(t, err) + agentID, _, serverTailnet := setupAgent(t, nil) - rp, release, err := serverTailnet.ReverseProxy(u, u, agentID) - require.NoError(t, err) - defer release() + u, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", codersdk.WorkspaceAgentHTTPAPIServerPort)) + require.NoError(t, err) - rw := httptest.NewRecorder() - req := httptest.NewRequest( - http.MethodGet, - u.String(), - nil, - ).WithContext(ctx) + rp, release, err := serverTailnet.ReverseProxy(u, u, agentID) + require.NoError(t, err) + defer release() - rp.ServeHTTP(rw, req) - res := rw.Result() - defer res.Body.Close() + rw := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodGet, + u.String(), + nil, + ).WithContext(ctx) - assert.Equal(t, http.StatusOK, res.StatusCode) -} + rp.ServeHTTP(rw, req) + res := rw.Result() + defer res.Body.Close() -func TestServerTailnet_ReverseProxy_Legacy(t *testing.T) { - t.Parallel() - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - // Force a connection through wsconncache using the legacy hardcoded ip. - agentID, _, serverTailnet := setupAgent(t, []netip.Prefix{ - netip.PrefixFrom(codersdk.WorkspaceAgentIP, 128), + assert.Equal(t, http.StatusOK, res.StatusCode) }) - u, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", codersdk.WorkspaceAgentHTTPAPIServerPort)) - require.NoError(t, err) + t.Run("HTTPSProxy", func(t *testing.T) { + t.Parallel() - rp, release, err := serverTailnet.ReverseProxy(u, u, agentID) - require.NoError(t, err) - defer release() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() - rw := httptest.NewRecorder() - req := httptest.NewRequest( - http.MethodGet, - u.String(), - nil, - ).WithContext(ctx) + agentID, _, serverTailnet := setupAgent(t, nil) - rp.ServeHTTP(rw, req) - res := rw.Result() - defer res.Body.Close() + const expectedResponseCode = 209 + // Test that we can proxy HTTPS traffic. + s := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(expectedResponseCode) + })) + t.Cleanup(s.Close) - assert.Equal(t, http.StatusOK, res.StatusCode) + uri, err := url.Parse(s.URL) + require.NoError(t, err) + + rp, release, err := serverTailnet.ReverseProxy(uri, uri, agentID) + require.NoError(t, err) + defer release() + + rw := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodGet, + uri.String(), + nil, + ).WithContext(ctx) + + rp.ServeHTTP(rw, req) + res := rw.Result() + defer res.Body.Close() + + assert.Equal(t, expectedResponseCode, res.StatusCode) + }) + + t.Run("Legacy", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Force a connection through wsconncache using the legacy hardcoded ip. + agentID, _, serverTailnet := setupAgent(t, []netip.Prefix{ + netip.PrefixFrom(codersdk.WorkspaceAgentIP, 128), + }) + + u, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", codersdk.WorkspaceAgentHTTPAPIServerPort)) + require.NoError(t, err) + + rp, release, err := serverTailnet.ReverseProxy(u, u, agentID) + require.NoError(t, err) + defer release() + + rw := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodGet, + u.String(), + nil, + ).WithContext(ctx) + + rp.ServeHTTP(rw, req) + res := rw.Result() + defer res.Body.Close() + + assert.Equal(t, http.StatusOK, res.StatusCode) + }) } func setupAgent(t *testing.T, agentAddresses []netip.Prefix) (uuid.UUID, agent.Agent, *coderd.ServerTailnet) { diff --git a/coderd/workspaceapps/apptest/apptest.go b/coderd/workspaceapps/apptest/apptest.go index 43ca833f9d..f64ba7c30b 100644 --- a/coderd/workspaceapps/apptest/apptest.go +++ b/coderd/workspaceapps/apptest/apptest.go @@ -349,6 +349,51 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { require.Equal(t, http.StatusOK, resp.StatusCode) }) + t.Run("ProxiesHTTPS", func(t *testing.T) { + t.Parallel() + + appDetails := setupProxyTest(t, &DeploymentOptions{ + ServeHTTPS: true, + }) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + u := appDetails.PathAppURL(appDetails.Apps.Owner) + resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, proxyTestAppBody, string(body)) + require.Equal(t, http.StatusOK, resp.StatusCode) + + var appTokenCookie *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == codersdk.DevURLSignedAppTokenCookie { + appTokenCookie = c + break + } + } + require.NotNil(t, appTokenCookie, "no signed app token cookie in response") + require.Equal(t, appTokenCookie.Path, u.Path, "incorrect path on app token cookie") + + // Ensure the signed app token cookie is valid. + appTokenClient := appDetails.AppClient(t) + appTokenClient.SetSessionToken("") + appTokenClient.HTTPClient.Jar, err = cookiejar.New(nil) + require.NoError(t, err) + appTokenClient.HTTPClient.Jar.SetCookies(u, []*http.Cookie{appTokenCookie}) + + resp, err = requestWithRetries(ctx, t, appTokenClient, http.MethodGet, u.String(), nil) + require.NoError(t, err) + defer resp.Body.Close() + body, err = io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, proxyTestAppBody, string(body)) + require.Equal(t, http.StatusOK, resp.StatusCode) + }) + t.Run("BlocksMe", func(t *testing.T) { t.Parallel() @@ -762,6 +807,50 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { require.Equal(t, http.StatusOK, resp.StatusCode) }) + t.Run("ProxiesHTTPS", func(t *testing.T) { + t.Parallel() + + appDetails := setupProxyTest(t, &DeploymentOptions{ + ServeHTTPS: true, + }) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + u := appDetails.SubdomainAppURL(appDetails.Apps.Owner) + resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, proxyTestAppBody, string(body)) + require.Equal(t, http.StatusOK, resp.StatusCode) + + var appTokenCookie *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == codersdk.DevURLSignedAppTokenCookie { + appTokenCookie = c + break + } + } + require.NotNil(t, appTokenCookie, "no signed token cookie in response") + require.Equal(t, appTokenCookie.Path, "/", "incorrect path on signed token cookie") + + // Ensure the signed app token cookie is valid. + appTokenClient := appDetails.AppClient(t) + appTokenClient.SetSessionToken("") + appTokenClient.HTTPClient.Jar, err = cookiejar.New(nil) + require.NoError(t, err) + appTokenClient.HTTPClient.Jar.SetCookies(u, []*http.Cookie{appTokenCookie}) + + resp, err = requestWithRetries(ctx, t, appTokenClient, http.MethodGet, u.String(), nil) + require.NoError(t, err) + defer resp.Body.Close() + body, err = io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, proxyTestAppBody, string(body)) + require.Equal(t, http.StatusOK, resp.StatusCode) + }) + t.Run("ProxiesPort", func(t *testing.T) { t.Parallel() @@ -928,8 +1017,8 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { forceURLTransport(t, client) // Create workspace. - port := appServer(t, nil) - workspace, _ = createWorkspaceWithApps(t, client, user.OrganizationIDs[0], user, port) + port := appServer(t, nil, false) + workspace, _ = createWorkspaceWithApps(t, client, user.OrganizationIDs[0], user, port, false) // Verify that the apps have the correct sharing levels set. workspaceBuild, err := client.WorkspaceBuild(ctx, workspace.LatestBuild.ID) diff --git a/coderd/workspaceapps/apptest/setup.go b/coderd/workspaceapps/apptest/setup.go index 916b6a09ba..4245204268 100644 --- a/coderd/workspaceapps/apptest/setup.go +++ b/coderd/workspaceapps/apptest/setup.go @@ -5,6 +5,7 @@ import ( "fmt" "net" "net/http" + "net/http/httptest" "net/url" "path" "strconv" @@ -48,6 +49,7 @@ type DeploymentOptions struct { DisableSubdomainApps bool DangerousAllowPathAppSharing bool DangerousAllowPathAppSiteOwnerAccess bool + ServeHTTPS bool // The following fields are only used by setupProxyTestWithFactory. noWorkspace bool @@ -185,9 +187,9 @@ func setupProxyTestWithFactory(t *testing.T, factory DeploymentFactory, opts *De } if opts.port == 0 { - opts.port = appServer(t, opts.headers) + opts.port = appServer(t, opts.headers, opts.ServeHTTPS) } - workspace, agnt := createWorkspaceWithApps(t, deployment.SDKClient, deployment.FirstUser.OrganizationID, me, opts.port) + workspace, agnt := createWorkspaceWithApps(t, deployment.SDKClient, deployment.FirstUser.OrganizationID, me, opts.port, opts.ServeHTTPS) details := &Details{ Deployment: deployment, @@ -234,60 +236,53 @@ func setupProxyTestWithFactory(t *testing.T, factory DeploymentFactory, opts *De return details } -func appServer(t *testing.T, headers http.Header) uint16 { - // Start a listener on a random port greater than the minimum app port. - var ( - ln net.Listener - tcpAddr *net.TCPAddr - ) - for i := 0; i < 32; i++ { - var err error - // #nosec - ln, err = net.Listen("tcp", ":0") - require.NoError(t, err) - - var ok bool - tcpAddr, ok = ln.Addr().(*net.TCPAddr) - require.True(t, ok) - if tcpAddr.Port < codersdk.WorkspaceAgentMinimumListeningPort { - _ = ln.Close() - ln = nil - time.Sleep(20 * time.Millisecond) - continue - } - } - require.NotNil(t, ln, "failed to find a free port greater than the minimum app port") - - server := http.Server{ - ReadHeaderTimeout: time.Minute, - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, err := r.Cookie(codersdk.SessionTokenCookie) - assert.ErrorIs(t, err, http.ErrNoCookie) - w.Header().Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For")) - for name, values := range headers { - for _, value := range values { - w.Header().Add(name, value) +//nolint:revive +func appServer(t *testing.T, headers http.Header, isHTTPS bool) uint16 { + server := httptest.NewUnstartedServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + _, err := r.Cookie(codersdk.SessionTokenCookie) + assert.ErrorIs(t, err, http.ErrNoCookie) + w.Header().Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For")) + for name, values := range headers { + for _, value := range values { + w.Header().Add(name, value) + } } - } - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(proxyTestAppBody)) - }), + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(proxyTestAppBody)) + }, + ), + ) + + server.Config.ReadHeaderTimeout = time.Minute + if isHTTPS { + server.StartTLS() + } else { + server.Start() } t.Cleanup(func() { - _ = server.Close() - _ = ln.Close() + server.Close() }) - go func() { - _ = server.Serve(ln) - }() - return uint16(tcpAddr.Port) + _, portStr, err := net.SplitHostPort(server.Listener.Addr().String()) + require.NoError(t, err) + port, err := strconv.ParseUint(portStr, 10, 16) + require.NoError(t, err) + + return uint16(port) } -func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.UUID, me codersdk.User, port uint16, workspaceMutators ...func(*codersdk.CreateWorkspaceRequest)) (codersdk.Workspace, codersdk.WorkspaceAgent) { +//nolint:revive +func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.UUID, me codersdk.User, port uint16, serveHTTPS bool, workspaceMutators ...func(*codersdk.CreateWorkspaceRequest)) (codersdk.Workspace, codersdk.WorkspaceAgent) { authToken := uuid.NewString() - appURL := fmt.Sprintf("http://127.0.0.1:%d?%s", port, proxyTestAppQuery) + scheme := "http" + if serveHTTPS { + scheme = "https" + } + + appURL := fmt.Sprintf("%s://127.0.0.1:%d?%s", scheme, port, proxyTestAppQuery) version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{ Parse: echo.ParseComplete, ProvisionPlan: echo.ProvisionComplete, diff --git a/coderd/wsconncache/wsconncache.go b/coderd/wsconncache/wsconncache.go index 13d1588384..4ff7f30e04 100644 --- a/coderd/wsconncache/wsconncache.go +++ b/coderd/wsconncache/wsconncache.go @@ -4,6 +4,7 @@ package wsconncache import ( "context" + "crypto/tls" "net/http" "net/http/httputil" "net/url" @@ -49,8 +50,9 @@ func (a *AgentProvider) ReverseProxy(targetURL *url.URL, dashboardURL *url.URL, return nil, nil, xerrors.Errorf("acquire agent connection: %w", err) } - proxy.Transport = conn.HTTPTransport() + transport := conn.HTTPTransport() + proxy.Transport = transport return proxy, release, nil } @@ -154,6 +156,18 @@ func (c *Cache) Acquire(id uuid.UUID) (*Conn, func(), error) { } transport := defaultTransport.Clone() transport.DialContext = agentConn.DialContext + + // We intentionally don't verify the certificate chain here. + // The connection to the workspace is already established and most + // apps are already going to be accessed over plain HTTP, this config + // simply allows apps being run over HTTPS to be accessed without error -- + // many of which may be using self-signed certs. + transport.TLSClientConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + //nolint:gosec + InsecureSkipVerify: true, + } + conn := &Conn{ WorkspaceAgentConn: agentConn, timeoutCancel: timeoutCancelFunc,