mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
255 lines
8.2 KiB
Go
255 lines
8.2 KiB
Go
package coderd
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/coder/coder/coderd/database"
|
|
"github.com/coder/coder/coderd/httpapi"
|
|
"github.com/coder/coder/coderd/httpmw"
|
|
"github.com/coder/coder/coderd/rbac"
|
|
"github.com/coder/coder/coderd/tracing"
|
|
"github.com/coder/coder/codersdk"
|
|
"github.com/coder/coder/site"
|
|
)
|
|
|
|
// workspaceAppsProxyPath proxies requests to a workspace application
|
|
// through a relative URL path.
|
|
func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) {
|
|
workspace := httpmw.WorkspaceParam(r)
|
|
agent := httpmw.WorkspaceAgentParam(r)
|
|
|
|
if !api.Authorize(r, rbac.ActionCreate, workspace.ExecutionRBAC()) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
|
|
// Determine the real path that was hit. The * URL parameter in Chi will not
|
|
// include the leading slash if it was present, so we need to add it back.
|
|
chiPath := chi.URLParam(r, "*")
|
|
basePath := strings.TrimSuffix(r.URL.Path, chiPath)
|
|
if strings.HasSuffix(basePath, "/") {
|
|
chiPath = "/" + chiPath
|
|
}
|
|
|
|
appName, port := httpapi.AppNameOrPort(chi.URLParam(r, "workspaceapp"))
|
|
api.proxyWorkspaceApplication(proxyApplication{
|
|
Workspace: workspace,
|
|
Agent: agent,
|
|
AppName: appName,
|
|
Port: port,
|
|
Path: chiPath,
|
|
DashboardOnError: true,
|
|
}, rw, r)
|
|
}
|
|
|
|
func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
host := httpapi.RequestHost(r)
|
|
if host == "" {
|
|
if r.URL.Path == "/derp" {
|
|
// The /derp endpoint is used by wireguard clients to tunnel
|
|
// through coderd. For some reason these requests don't set
|
|
// a Host header properly sometimes (no idea how), which
|
|
// causes this path to get hit.
|
|
next.ServeHTTP(rw, r)
|
|
return
|
|
}
|
|
|
|
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Could not determine request Host.",
|
|
})
|
|
return
|
|
}
|
|
|
|
app, err := httpapi.ParseSubdomainAppURL(host)
|
|
if err != nil {
|
|
// Subdomain is not a valid application url. Pass through to the
|
|
// rest of the app.
|
|
// TODO: @emyrk we should probably catch invalid subdomains. Meaning
|
|
// an invalid application should not route to the coderd.
|
|
// To do this we would need to know the list of valid access urls
|
|
// though?
|
|
next.ServeHTTP(rw, r)
|
|
return
|
|
}
|
|
|
|
workspaceAgentKey := fmt.Sprintf("%s.%s", app.WorkspaceName, app.AgentName)
|
|
chiCtx := chi.RouteContext(ctx)
|
|
chiCtx.URLParams.Add("workspace_and_agent", workspaceAgentKey)
|
|
chiCtx.URLParams.Add("user", app.Username)
|
|
|
|
// Use the passed in app middlewares before passing to the proxy app.
|
|
mws := chi.Middlewares(middlewares)
|
|
mws.Handler(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
workspace := httpmw.WorkspaceParam(r)
|
|
agent := httpmw.WorkspaceAgentParam(r)
|
|
|
|
api.proxyWorkspaceApplication(proxyApplication{
|
|
Workspace: workspace,
|
|
Agent: agent,
|
|
AppName: app.AppName,
|
|
Port: app.Port,
|
|
Path: r.URL.Path,
|
|
DashboardOnError: false,
|
|
}, rw, r)
|
|
})).ServeHTTP(rw, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
// proxyApplication are the required fields to proxy a workspace application.
|
|
type proxyApplication struct {
|
|
Workspace database.Workspace
|
|
Agent database.WorkspaceAgent
|
|
|
|
// Either AppName or Port must be set, but not both.
|
|
AppName string
|
|
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) {
|
|
if !api.Authorize(r, rbac.ActionCreate, proxyApp.Workspace.ExecutionRBAC()) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
|
|
// If the app does not exist, but the app name is a port number, then
|
|
// route to the port as an "anonymous app". We only support HTTP for
|
|
// port-based URLs.
|
|
internalURL := fmt.Sprintf("http://127.0.0.1:%d", proxyApp.Port)
|
|
|
|
// If the app name was used instead, fetch the app from the database so we
|
|
// can get the internal URL.
|
|
if proxyApp.AppName != "" {
|
|
app, err := api.Database.GetWorkspaceAppByAgentIDAndName(r.Context(), database.GetWorkspaceAppByAgentIDAndNameParams{
|
|
AgentID: proxyApp.Agent.ID,
|
|
Name: proxyApp.AppName,
|
|
})
|
|
if err != nil {
|
|
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching workspace application.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
if !app.Url.Valid {
|
|
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: fmt.Sprintf("Application %s does not have a url.", app.Name),
|
|
})
|
|
return
|
|
}
|
|
internalURL = app.Url.String
|
|
}
|
|
|
|
appURL, err := url.Parse(internalURL)
|
|
if err != nil {
|
|
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: fmt.Sprintf("App URL %q is invalid.", internalURL),
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Ensure path and query parameter correctness.
|
|
if proxyApp.Path == "" {
|
|
// Web applications typically request paths relative to the
|
|
// root URL. This allows for routing behind a proxy or subpath.
|
|
// See https://github.com/coder/code-server/issues/241 for examples.
|
|
http.Redirect(rw, r, r.URL.Path+"/", http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
if proxyApp.Path == "/" && r.URL.RawQuery == "" && appURL.RawQuery != "" {
|
|
// If the application defines a default set of query parameters,
|
|
// we should always respect them. The reverse proxy will merge
|
|
// query parameters for server-side requests, but sometimes
|
|
// client-side applications require the query parameters to render
|
|
// properly. With code-server, this is the "folder" param.
|
|
r.URL.RawQuery = appURL.RawQuery
|
|
http.Redirect(rw, r, r.URL.String(), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
|
|
r.URL.Path = proxyApp.Path
|
|
appURL.RawQuery = ""
|
|
|
|
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(r.Context(), site.APIResponse{
|
|
StatusCode: http.StatusBadGateway,
|
|
Message: err.Error(),
|
|
}))
|
|
api.siteHandler.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
httpapi.Write(w, http.StatusBadGateway, codersdk.Response{
|
|
Message: "Failed to proxy request to application.",
|
|
Detail: err.Error(),
|
|
})
|
|
}
|
|
|
|
conn, release, err := api.workspaceAgentCache.Acquire(r, proxyApp.Agent.ID)
|
|
if err != nil {
|
|
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to dial workspace agent.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
defer release()
|
|
|
|
// This strips the session token from a workspace app request.
|
|
cookieHeaders := r.Header.Values("Cookie")[:]
|
|
r.Header.Del("Cookie")
|
|
for _, cookieHeader := range cookieHeaders {
|
|
r.Header.Add("Cookie", httpapi.StripCoderCookies(cookieHeader))
|
|
}
|
|
proxy.Transport = conn.HTTPTransport()
|
|
|
|
// end span so we don't get long lived trace data
|
|
tracing.EndHTTPSpan(r, 200)
|
|
|
|
proxy.ServeHTTP(rw, r)
|
|
}
|
|
|
|
// applicationCookie is a helper function to copy the auth cookie to also
|
|
// support subdomains. Until we support creating authentication cookies that can
|
|
// only do application authentication, we will just reuse the original token.
|
|
// This code should be temporary and be replaced with something that creates
|
|
// a unique session_token.
|
|
//
|
|
// Returns nil if the access URL isn't a hostname.
|
|
func (api *API) applicationCookie(authCookie *http.Cookie) *http.Cookie {
|
|
if net.ParseIP(api.AccessURL.Hostname()) != nil {
|
|
return nil
|
|
}
|
|
|
|
appCookie := *authCookie
|
|
// We only support setting this cookie on the access URL subdomains. This is
|
|
// to ensure we don't accidentally leak the auth cookie to subdomains on
|
|
// another hostname.
|
|
appCookie.Domain = "." + api.AccessURL.Hostname()
|
|
return &appCookie
|
|
}
|