feat: static error page in applications handlers (#4299)

This commit is contained in:
Dean Sheather 2022-10-05 02:30:55 +10:00 committed by GitHub
parent ce953441fb
commit d165d76338
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 172 additions and 220 deletions

View File

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
@ -66,10 +67,9 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
Workspace: workspace,
Agent: agent,
// We do not support port proxying for paths.
AppName: chi.URLParam(r, "workspaceapp"),
Port: 0,
Path: chiPath,
DashboardOnError: true,
AppName: chi.URLParam(r, "workspaceapp"),
Port: 0,
Path: chiPath,
}, rw, r)
}
@ -162,12 +162,11 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht
}
api.proxyWorkspaceApplication(proxyApplication{
Workspace: workspace,
Agent: agent,
AppName: app.AppName,
Port: app.Port,
Path: r.URL.Path,
DashboardOnError: false,
Workspace: workspace,
Agent: agent,
AppName: app.AppName,
Port: app.Port,
Path: r.URL.Path,
}, rw, r)
})).ServeHTTP(rw, r.WithContext(ctx))
})
@ -175,20 +174,19 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht
}
func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *http.Request, next http.Handler, host string) (httpapi.ApplicationURL, bool) {
ctx := r.Context()
// Check if the hostname matches the access URL. If it does, the
// user was definitely trying to connect to the dashboard/API.
// Check if the hostname matches the access URL. If it does, the user was
// definitely trying to connect to the dashboard/API.
if httpapi.HostnamesMatch(api.AccessURL.Hostname(), host) {
next.ServeHTTP(rw, r)
return httpapi.ApplicationURL{}, false
}
// Split the subdomain so we can parse the application details and
// verify it matches the configured app hostname later.
// Split the subdomain so we can parse the application details and verify it
// matches the configured app hostname later.
subdomain, rest := httpapi.SplitSubdomain(host)
if rest == "" {
// If there are no periods in the hostname, then it can't be a
// valid application URL.
// If there are no periods in the hostname, then it can't be a valid
// application URL.
next.ServeHTTP(rw, r)
return httpapi.ApplicationURL{}, false
}
@ -197,27 +195,34 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt
// Parse the application URL from the subdomain.
app, err := httpapi.ParseSubdomainAppURL(subdomain)
if err != nil {
// If it isn't a valid app URL and the base domain doesn't match
// the configured app hostname, this request was probably
// destined for the dashboard/API router.
// If it isn't a valid app URL and the base domain doesn't match the
// configured app hostname, this request was probably destined for the
// dashboard/API router.
if !matchingBaseHostname {
next.ServeHTTP(rw, r)
return httpapi.ApplicationURL{}, false
}
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Could not parse subdomain application URL.",
Detail: err.Error(),
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadRequest,
Title: "Invalid application URL",
Description: fmt.Sprintf("Could not parse subdomain application URL %q: %s", subdomain, err.Error()),
RetryEnabled: false,
DashboardURL: api.AccessURL.String(),
})
return httpapi.ApplicationURL{}, false
}
// At this point we've verified that the subdomain looks like a
// valid application URL, so the base hostname should match the
// configured app hostname.
// At this point we've verified that the subdomain looks like a valid
// application URL, so the base hostname should match the configured app
// hostname.
if !matchingBaseHostname {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "The server does not accept application requests on this hostname.",
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusNotFound,
Title: "Not Found",
Description: "The server does not accept application requests on this hostname.",
RetryEnabled: false,
DashboardURL: api.AccessURL.String(),
})
return httpapi.ApplicationURL{}, false
}
@ -230,12 +235,10 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt
// they will be redirected to the route below. If the user does have a session
// key but insufficient permissions a static error page will be rendered.
func (api *API) verifyWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, workspace database.Workspace, host string) bool {
ctx := r.Context()
_, ok := httpmw.APIKeyOptional(r)
if ok {
if !api.Authorize(r, rbac.ActionCreate, workspace.ApplicationConnectRBAC()) {
// TODO: This should be a static error page.
httpapi.ResourceNotFound(rw)
renderApplicationNotFound(rw, r, api.AccessURL)
return false
}
@ -249,9 +252,14 @@ func (api *API) verifyWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.R
// Exchange the encoded API key for a real one.
_, apiKey, err := decryptAPIKey(r.Context(), api.Database, encryptedAPIKey)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Could not decrypt API key. Please remove the query parameter and try again.",
Detail: err.Error(),
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadRequest,
Title: "Bad Request",
Description: "Could not decrypt API key. Please remove the query parameter and try again.",
// Retry is disabled because the user needs to remove the query
// parameter before they try again.
RetryEnabled: false,
DashboardURL: api.AccessURL.String(),
})
return false
}
@ -302,6 +310,10 @@ func (api *API) verifyWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.R
// workspaceApplicationAuth is an endpoint on the main router that handles
// redirects from the subdomain handler.
//
// This endpoint is under /api so we don't return the friendly error page here.
// Any errors on this endpoint should be errors that are unlikely to happen
// in production unless the user messes with the URL.
func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if api.AppHostname == "" {
@ -413,11 +425,6 @@ type proxyApplication struct {
Port uint16
// Path must either be empty or have a leading slash.
Path string
// DashboardOnError determines whether or not the dashboard should be
// rendered on error. This should be set for proxy path URLs but not
// hostname based URLs.
DashboardOnError bool
}
func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.ResponseWriter, r *http.Request) {
@ -439,17 +446,28 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res
AgentID: proxyApp.Agent.ID,
Name: proxyApp.AppName,
})
if xerrors.Is(err, sql.ErrNoRows) {
renderApplicationNotFound(rw, r, api.AccessURL)
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace application.",
Detail: err.Error(),
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusInternalServerError,
Title: "Internal Server Error",
Description: "Could not fetch workspace application: " + err.Error(),
RetryEnabled: true,
DashboardURL: api.AccessURL.String(),
})
return
}
if !app.Url.Valid {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Application %s does not have a url.", app.Name),
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadRequest,
Title: "Bad Request",
Description: fmt.Sprintf("Application %q does not have a URL set.", app.Name),
RetryEnabled: true,
DashboardURL: api.AccessURL.String(),
})
return
}
@ -458,9 +476,12 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res
appURL, err := url.Parse(internalURL)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("App URL %q is invalid.", internalURL),
Detail: err.Error(),
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadRequest,
Title: "Bad Request",
Description: fmt.Sprintf("Application has an invalid URL %q: %s", internalURL, err.Error()),
RetryEnabled: true,
DashboardURL: api.AccessURL.String(),
})
return
}
@ -489,28 +510,23 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res
proxy := httputil.NewSingleHostReverseProxy(appURL)
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
if proxyApp.DashboardOnError {
// To pass friendly errors to the frontend, special meta tags are
// overridden in the index.html with the content passed here.
r = r.WithContext(site.WithAPIResponse(ctx, site.APIResponse{
StatusCode: http.StatusBadGateway,
Message: err.Error(),
}))
api.siteHandler.ServeHTTP(w, r)
return
}
httpapi.Write(ctx, w, http.StatusBadGateway, codersdk.Response{
Message: "Failed to proxy request to application.",
Detail: err.Error(),
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadGateway,
Title: "Bad Gateway",
Description: "Failed to proxy request to application: " + err.Error(),
RetryEnabled: true,
DashboardURL: api.AccessURL.String(),
})
}
conn, release, err := api.workspaceAgentCache.Acquire(r, proxyApp.Agent.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to dial workspace agent.",
Detail: err.Error(),
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadGateway,
Title: "Bad Gateway",
Description: "Could not connect to workspace agent: " + err.Error(),
RetryEnabled: true,
DashboardURL: api.AccessURL.String(),
})
return
}
@ -648,3 +664,15 @@ func decryptAPIKey(ctx context.Context, db database.Store, encryptedAPIKey strin
return key, payload.APIKey, nil
}
// renderApplicationNotFound should always be used when the app is not found or
// the current user doesn't have permission to access it.
func renderApplicationNotFound(rw http.ResponseWriter, r *http.Request, accessURL *url.URL) {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusNotFound,
Title: "Application not found",
Description: "The application or workspace you are trying to access does not exist.",
RetryEnabled: false,
DashboardURL: accessURL.String(),
})
}

View File

@ -258,8 +258,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
resp, err := client.Request(ctx, http.MethodGet, "/@me/"+workspace.Name+"/apps/fake/", nil)
require.NoError(t, err)
defer resp.Body.Close()
// this is 200 OK because it returns a dashboard page
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, http.StatusBadGateway, resp.StatusCode)
})
}
@ -529,10 +528,9 @@ func TestWorkspaceAppsProxySubdomainBlocked(t *testing.T) {
// Should have an error response.
require.Equal(t, http.StatusNotFound, resp.StatusCode)
var resBody codersdk.Response
err = json.NewDecoder(resp.Body).Decode(&resBody)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Contains(t, resBody.Message, "does not accept application requests on this hostname")
require.Contains(t, string(body), "does not accept application requests on this hostname")
})
t.Run("InvalidSubdomain", func(t *testing.T) {
@ -547,12 +545,11 @@ func TestWorkspaceAppsProxySubdomainBlocked(t *testing.T) {
require.NoError(t, err)
defer resp.Body.Close()
// Should have an error response.
// Should have a HTML error response.
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
var resBody codersdk.Response
err = json.NewDecoder(resp.Body).Decode(&resBody)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Contains(t, resBody.Message, "Could not parse subdomain application URL")
require.Contains(t, string(body), "Could not parse subdomain application URL")
})
}

View File

@ -16,11 +16,6 @@
<meta property="og:type" content="website" />
<meta property="csp-nonce" content="{{ .CSP.Nonce }}" />
<meta property="csrf-token" content="{{ .CSRF.Token }}" />
<meta
id="api-response"
data-statuscode="{{ .APIResponse.StatusCode }}"
data-message="{{ .APIResponse.Message }}"
/>
<!-- We need to set data-react-helmet to be able to override it in the workspace page -->
<link
rel="alternate icon"

View File

@ -3,11 +3,12 @@ package site
import (
"archive/tar"
"bytes"
"context"
"crypto/sha1" //#nosec // Not used for cryptography.
_ "embed"
"encoding/hex"
"errors"
"fmt"
htmltemplate "html/template"
"io"
"io/fs"
"net/http"
@ -24,15 +25,26 @@ import (
"golang.org/x/exp/slices"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/codersdk"
)
type apiResponseContextKey struct{}
// We always embed the error page HTML because it it doesn't need to be built,
// and it's tiny and doesn't contribute much to the binary size.
var (
//go:embed static/error.html
errorHTML string
// WithAPIResponse returns a context with the APIResponse value attached.
// This is used to inject API response data to the index.html for additional
// metadata in error pages.
func WithAPIResponse(ctx context.Context, apiResponse APIResponse) context.Context {
return context.WithValue(ctx, apiResponseContextKey{}, apiResponse)
errorTemplate *htmltemplate.Template
)
func init() {
var err error
errorTemplate, err = htmltemplate.New("error").Parse(errorHTML)
if err != nil {
panic(err)
}
}
// Handler returns an HTTP handler for serving the static site.
@ -87,14 +99,8 @@ func (h *handler) exists(filePath string) bool {
}
type htmlState struct {
APIResponse APIResponse
CSP cspState
CSRF csrfState
}
type APIResponse struct {
StatusCode int
Message string
CSP cspState
CSRF csrfState
}
type cspState struct {
@ -142,15 +148,6 @@ func (h *handler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
CSRF: csrfState{Token: nosurf.Token(req)},
}
apiResponseRaw := req.Context().Value(apiResponseContextKey{})
if apiResponseRaw != nil {
apiResponse, ok := apiResponseRaw.(APIResponse)
if !ok {
panic("dev error: api response in context isn't the correct type")
}
state.APIResponse = apiResponse
}
// First check if it's a file we have in our templates
if h.serveHTML(resp, req, reqFile, state) {
return
@ -628,3 +625,34 @@ func extractBin(dest string, r io.Reader) (numExtracted int, err error) {
n++
}
}
// ErrorPageData contains the variables that are found in
// site/static/error.html.
type ErrorPageData struct {
Status int
Title string
Description string
RetryEnabled bool
DashboardURL string
}
// RenderStaticErrorPage renders the static error page. This is used by app
// requests to avoid dependence on the dashboard but maintain the ability to
// render a friendly error page on subdomains.
func RenderStaticErrorPage(rw http.ResponseWriter, r *http.Request, data ErrorPageData) {
type outerData struct {
Error ErrorPageData
}
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
rw.WriteHeader(data.Status)
err := errorTemplate.Execute(rw, outerData{Error: data})
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to render error page: " + err.Error(),
Detail: fmt.Sprintf("Original error was: %d %s, %s", data.Status, data.Title, data.Description),
})
return
}
}

View File

@ -3,7 +3,6 @@ package site_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/fs"
@ -11,6 +10,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"testing/fstest"
@ -441,41 +441,32 @@ func TestExtractOrReadBinFS(t *testing.T) {
})
}
func TestServeAPIResponse(t *testing.T) {
func TestRenderStaticErrorPage(t *testing.T) {
t.Parallel()
// Create a test server
rootFS := fstest.MapFS{
"index.html": &fstest.MapFile{
Data: []byte(`{"code":{{ .APIResponse.StatusCode }},"message":"{{ .APIResponse.Message }}"}`),
},
d := site.ErrorPageData{
Status: http.StatusBadGateway,
Title: "Bad Gateway 1234",
Description: "shout out colin",
RetryEnabled: true,
DashboardURL: "https://example.com",
}
binFS := http.FS(fstest.MapFS{})
apiResponse := site.APIResponse{
StatusCode: http.StatusBadGateway,
Message: "This could be an error message!",
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(site.WithAPIResponse(r.Context(), apiResponse))
site.Handler(rootFS, binFS).ServeHTTP(w, r)
}))
defer srv.Close()
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
site.RenderStaticErrorPage(rw, r, d)
req, err := http.NewRequestWithContext(context.Background(), "GET", srv.URL, nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp := rw.Result()
defer resp.Body.Close()
var body struct {
Code int `json:"code"`
Message string `json:"message"`
}
data, err := io.ReadAll(resp.Body)
require.Equal(t, d.Status, resp.StatusCode)
require.Contains(t, resp.Header.Get("Content-Type"), "text/html")
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
t.Logf("resp: %q", data)
err = json.Unmarshal(data, &body)
require.NoError(t, err)
require.Equal(t, apiResponse.StatusCode, body.Code)
require.Equal(t, apiResponse.Message, body.Message)
bodyStr := string(body)
require.Contains(t, bodyStr, strconv.Itoa(d.Status))
require.Contains(t, bodyStr, d.Title)
require.Contains(t, bodyStr, d.Description)
require.Contains(t, bodyStr, "Retry")
require.Contains(t, bodyStr, d.DashboardURL)
}

View File

@ -34,9 +34,6 @@ const WorkspacePage = lazy(() => import("./pages/WorkspacePage/WorkspacePage"))
const WorkspaceSchedulePage = lazy(
() => import("./pages/WorkspaceSchedulePage/WorkspaceSchedulePage"),
)
const WorkspaceAppErrorPage = lazy(
() => import("./pages/WorkspaceAppErrorPage/WorkspaceAppErrorPage"),
)
const TerminalPage = lazy(() => import("./pages/TerminalPage/TerminalPage"))
const CreateWorkspacePage = lazy(() => import("./pages/CreateWorkspacePage/CreateWorkspacePage"))
const TemplatePage = lazy(() => import("./pages/TemplatePage/TemplatePage"))
@ -189,17 +186,6 @@ export const AppRouter: FC = () => {
}
/>
<Route path="apps">
<Route
path=":app/*"
element={
<AuthAndFrame>
<WorkspaceAppErrorPage />
</AuthAndFrame>
}
/>
</Route>
<Route
path="builds/:buildNumber"
element={

View File

@ -1,18 +0,0 @@
import { FC, useMemo } from "react"
import { useParams } from "react-router-dom"
import { WorkspaceAppErrorPageView } from "./WorkspaceAppErrorPageView"
const WorkspaceAppErrorView: FC = () => {
const { app } = useParams()
const message = useMemo(() => {
const tag = document.getElementById("api-response")
if (!tag) {
throw new Error("dev error: api-response meta tag not found")
}
return tag.getAttribute("data-message") as string
}, [])
return <WorkspaceAppErrorPageView appName={app as string} message={message} />
}
export default WorkspaceAppErrorView

View File

@ -1,22 +0,0 @@
import { Story } from "@storybook/react"
import {
WorkspaceAppErrorPageView,
WorkspaceAppErrorPageViewProps,
} from "./WorkspaceAppErrorPageView"
export default {
title: "pages/WorkspaceAppErrorPageView",
component: WorkspaceAppErrorPageView,
}
const Template: Story<WorkspaceAppErrorPageViewProps> = (args) => (
<WorkspaceAppErrorPageView {...args} />
)
export const NotRunning = Template.bind({})
NotRunning.args = {
appName: "code-server",
// This is a real message copied and pasted from the backend!
message:
"remote dial error: dial 'tcp://localhost:13337': dial tcp 127.0.0.1:13337: connect: connection refused",
}

View File

@ -1,33 +0,0 @@
import { makeStyles } from "@material-ui/core/styles"
import { FC } from "react"
export interface WorkspaceAppErrorPageViewProps {
appName: string
message: string
}
export const WorkspaceAppErrorPageView: FC<
React.PropsWithChildren<WorkspaceAppErrorPageViewProps>
> = (props) => {
const styles = useStyles()
return (
<div className={styles.root}>
<h1 className={styles.title}>{props.appName} is offline!</h1>
<p className={styles.message}>{props.message}</p>
</div>
)
}
const useStyles = makeStyles((theme) => ({
root: {
flex: 1,
padding: theme.spacing(10),
},
title: {
textAlign: "center",
},
message: {
textAlign: "center",
},
}))