2023-04-05 18:41:55 +00:00
package workspaceapps
import (
"context"
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"go.opentelemetry.io/otel/trace"
"nhooyr.io/websocket"
"cdr.dev/slog"
2023-08-18 18:55:43 +00:00
"github.com/coder/coder/v2/agent/agentssh"
2023-09-01 16:50:12 +00:00
"github.com/coder/coder/v2/coderd/database/dbtime"
2023-08-18 18:55:43 +00:00
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/coderd/util/slice"
2024-01-17 16:41:42 +00:00
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
2023-08-18 18:55:43 +00:00
"github.com/coder/coder/v2/codersdk"
2024-03-26 17:44:31 +00:00
"github.com/coder/coder/v2/codersdk/workspacesdk"
2023-08-18 18:55:43 +00:00
"github.com/coder/coder/v2/site"
2023-04-05 18:41:55 +00:00
)
const (
// This needs to be a super unique query parameter because we don't want to
// conflict with query parameters that users may use.
//nolint:gosec
SubdomainProxyAPIKeyParam = "coder_application_connect_api_key_35e783"
// appLogoutHostname is the hostname to use for the logout redirect. When
// the dashboard logs out, it will redirect to this subdomain of the app
// hostname, and the server will remove the cookie and redirect to the main
// login page.
// It is important that this URL can never match a valid app hostname.
//
// DEPRECATED: we no longer use this, but we still redirect from it to the
// main login page.
appLogoutHostname = "coder-logout"
)
// nonCanonicalHeaders is a map from "canonical" headers to the actual header we
// should send to the app in the workspace. Some headers (such as the websocket
// upgrade headers from RFC 6455) are not canonical according to the HTTP/1
// spec. Golang has said that they will not add custom cases for these headers,
// so we need to do it ourselves.
//
// Some apps our customers use are sensitive to the case of these headers.
//
// https://github.com/golang/go/issues/18495
var nonCanonicalHeaders = map [ string ] string {
"Sec-Websocket-Accept" : "Sec-WebSocket-Accept" ,
"Sec-Websocket-Extensions" : "Sec-WebSocket-Extensions" ,
"Sec-Websocket-Key" : "Sec-WebSocket-Key" ,
"Sec-Websocket-Protocol" : "Sec-WebSocket-Protocol" ,
"Sec-Websocket-Version" : "Sec-WebSocket-Version" ,
}
2023-07-12 22:37:31 +00:00
type AgentProvider interface {
// ReverseProxy returns an httputil.ReverseProxy for proxying HTTP requests
// to the specified agent.
2024-04-26 15:52:53 +00:00
ReverseProxy ( targetURL , dashboardURL * url . URL , agentID uuid . UUID , app appurl . ApplicationURL , wildcardHost string ) * httputil . ReverseProxy
2023-07-12 22:37:31 +00:00
// AgentConn returns a new connection to the specified agent.
2024-03-26 17:44:31 +00:00
AgentConn ( ctx context . Context , agentID uuid . UUID ) ( _ * workspacesdk . AgentConn , release func ( ) , _ error )
2023-07-12 22:37:31 +00:00
2023-11-13 23:14:12 +00:00
ServeHTTPDebug ( w http . ResponseWriter , r * http . Request )
2023-07-12 22:37:31 +00:00
Close ( ) error
}
2023-04-05 18:41:55 +00:00
// Server serves workspace apps endpoints, including:
// - Path-based apps
// - Subdomain app middleware
// - Workspace reconnecting-pty (aka. web terminal)
type Server struct {
Logger slog . Logger
// DashboardURL should be a url to the coderd dashboard. This can be the
// same as the AccessURL if the Server is embedded.
DashboardURL * url . URL
AccessURL * url . URL
// Hostname should be the wildcard hostname to use for workspace
// applications INCLUDING the asterisk, (optional) suffix and leading dot.
// It will use the same scheme and port number as the access URL.
// E.g. "*.apps.coder.com" or "*-apps.coder.com".
Hostname string
// HostnameRegex contains the regex version of Hostname as generated by
2024-01-17 16:41:42 +00:00
// appurl.CompileHostnamePattern(). It MUST be set if Hostname is set.
2023-04-17 19:57:21 +00:00
HostnameRegex * regexp . Regexp
RealIPConfig * httpmw . RealIPConfig
2023-04-05 18:41:55 +00:00
SignedTokenProvider SignedTokenProvider
AppSecurityKey SecurityKey
2023-04-17 19:57:21 +00:00
// DisablePathApps disables path-based apps. This is a security feature as path
// based apps share the same cookie as the dashboard, and are susceptible to XSS
// by a malicious workspace app.
//
// Subdomain apps are safer with their cookies scoped to the subdomain, and XSS
// calls to the dashboard are not possible due to CORs.
DisablePathApps bool
SecureAuthCookie bool
2023-08-16 12:22:00 +00:00
AgentProvider AgentProvider
StatsCollector * StatsCollector
2023-07-12 22:37:31 +00:00
2023-04-05 18:41:55 +00:00
websocketWaitMutex sync . Mutex
websocketWaitGroup sync . WaitGroup
}
// Close waits for all reconnecting-pty WebSocket connections to drain before
// returning.
func ( s * Server ) Close ( ) error {
s . websocketWaitMutex . Lock ( )
s . websocketWaitGroup . Wait ( )
s . websocketWaitMutex . Unlock ( )
2023-08-16 12:22:00 +00:00
if s . StatsCollector != nil {
_ = s . StatsCollector . Close ( )
}
2023-07-12 22:37:31 +00:00
// The caller must close the SignedTokenProvider and the AgentProvider (if
// necessary).
2023-04-05 18:41:55 +00:00
return nil
}
func ( s * Server ) Attach ( r chi . Router ) {
servePathApps := func ( r chi . Router ) {
r . HandleFunc ( "/*" , s . workspaceAppsProxyPath )
}
// %40 is the encoded character of the @ symbol. VS Code Web does
// not handle character encoding properly, so it's safe to assume
// other applications might not as well.
r . Route ( "/%40{user}/{workspace_and_agent}/apps/{workspaceapp}" , servePathApps )
r . Route ( "/@{user}/{workspace_and_agent}/apps/{workspaceapp}" , servePathApps )
r . Get ( "/api/v2/workspaceagents/{workspaceagent}/pty" , s . workspaceAgentPTY )
}
2023-04-17 19:57:21 +00:00
// handleAPIKeySmuggling is called by the proxy path and subdomain handlers to
// process any "smuggled" API keys in the query parameters.
//
// If a smuggled key is found, it is decrypted and the cookie is set, and the
// user is redirected to strip the query parameter.
func ( s * Server ) handleAPIKeySmuggling ( rw http . ResponseWriter , r * http . Request , accessMethod AccessMethod ) bool {
ctx := r . Context ( )
encryptedAPIKey := r . URL . Query ( ) . Get ( SubdomainProxyAPIKeyParam )
if encryptedAPIKey == "" {
return true
}
// API key smuggling is not permitted for path apps on the primary access
// URL. The user is already covered by their full session token.
if accessMethod == AccessMethodPath && s . AccessURL . Host == s . DashboardURL . Host {
site . RenderStaticErrorPage ( rw , r , site . ErrorPageData {
Status : http . StatusBadRequest ,
Title : "Bad Request" ,
Description : "Could not decrypt API key. Workspace app API key smuggling is not permitted on the primary access URL. 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 : s . DashboardURL . String ( ) ,
} )
return false
}
// Exchange the encoded API key for a real one.
token , err := s . AppSecurityKey . DecryptAPIKey ( encryptedAPIKey )
if err != nil {
s . Logger . Debug ( ctx , "could not decrypt smuggled workspace app API key" , slog . Error ( err ) )
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 : s . DashboardURL . String ( ) ,
} )
return false
}
// Set the cookie. For subdomain apps, we set the cookie on the whole
// wildcard so users don't need to re-auth for every subdomain app they
// access. For path apps (only on proxies, see above) we just set it on the
// current domain.
domain := "" // use the current domain
if accessMethod == AccessMethodSubdomain {
hostSplit := strings . SplitN ( s . Hostname , "." , 2 )
if len ( hostSplit ) != 2 {
// This should be impossible as we verify the app hostname on
// startup, but we'll check anyways.
s . Logger . Error ( r . Context ( ) , "could not split invalid app hostname" , slog . F ( "hostname" , s . Hostname ) )
site . RenderStaticErrorPage ( rw , r , site . ErrorPageData {
Status : http . StatusInternalServerError ,
Title : "Internal Server Error" ,
Description : "The app is configured with an invalid app wildcard hostname. Please contact an administrator." ,
RetryEnabled : false ,
DashboardURL : s . DashboardURL . String ( ) ,
} )
return false
}
// Set the cookie for all subdomains of s.Hostname.
domain = "." + hostSplit [ 1 ]
}
// We don't set an expiration because the key in the database already has an
// expiration, and expired tokens don't affect the user experience (they get
// auto-redirected to re-smuggle the API key).
2023-08-29 01:34:52 +00:00
//
// We use different cookie names for path apps and for subdomain apps to
// avoid both being set and sent to the server at the same time and the
// server using the wrong value.
2023-04-17 19:57:21 +00:00
http . SetCookie ( rw , & http . Cookie {
2023-08-29 01:34:52 +00:00
Name : AppConnectSessionTokenCookieName ( accessMethod ) ,
2023-04-17 19:57:21 +00:00
Value : token ,
Domain : domain ,
Path : "/" ,
MaxAge : 0 ,
HttpOnly : true ,
SameSite : http . SameSiteLaxMode ,
Secure : s . SecureAuthCookie ,
} )
// Strip the query parameter.
path := r . URL . Path
if path == "" {
path = "/"
}
q := r . URL . Query ( )
q . Del ( SubdomainProxyAPIKeyParam )
rawQuery := q . Encode ( )
if rawQuery != "" {
path += "?" + q . Encode ( )
}
http . Redirect ( rw , r , path , http . StatusSeeOther )
return false
}
2023-04-05 18:41:55 +00:00
// workspaceAppsProxyPath proxies requests to a workspace application
// through a relative URL path.
func ( s * Server ) workspaceAppsProxyPath ( rw http . ResponseWriter , r * http . Request ) {
2023-04-17 19:57:21 +00:00
if s . DisablePathApps {
2023-04-05 18:41:55 +00:00
site . RenderStaticErrorPage ( rw , r , site . ErrorPageData {
2023-08-30 21:14:24 +00:00
Status : http . StatusForbidden ,
Title : "Forbidden" ,
2023-04-05 18:41:55 +00:00
Description : "Path-based applications are disabled on this Coder deployment by the administrator." ,
RetryEnabled : false ,
DashboardURL : s . DashboardURL . String ( ) ,
} )
return
}
// We don't support @me in path apps since it requires the database to
// lookup the username from token. We used to redirect by doing this lookup.
if chi . URLParam ( r , "user" ) == codersdk . Me {
site . RenderStaticErrorPage ( rw , r , site . ErrorPageData {
Status : http . StatusNotFound ,
Title : "Application Not Found" ,
Description : "Applications must be accessed with the full username, not @me." ,
RetryEnabled : false ,
DashboardURL : s . DashboardURL . String ( ) ,
} )
return
}
2023-04-17 19:57:21 +00:00
if ! s . handleAPIKeySmuggling ( rw , r , AccessMethodPath ) {
return
}
2023-04-05 18:41:55 +00:00
// 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
}
// ResolveRequest will only return a new signed token if the actor has the RBAC
// permissions to connect to a workspace.
2023-04-17 19:57:21 +00:00
token , ok := ResolveRequest ( rw , r , ResolveRequestOptions {
Logger : s . Logger ,
SignedTokenProvider : s . SignedTokenProvider ,
DashboardURL : s . DashboardURL ,
PathAppBaseURL : s . AccessURL ,
AppHostname : s . Hostname ,
AppRequest : Request {
AccessMethod : AccessMethodPath ,
BasePath : basePath ,
2023-10-10 20:02:39 +00:00
Prefix : "" , // Prefix doesn't exist for path apps
2023-04-17 19:57:21 +00:00
UsernameOrID : chi . URLParam ( r , "user" ) ,
WorkspaceAndAgent : chi . URLParam ( r , "workspace_and_agent" ) ,
// We don't support port proxying on paths. The ResolveRequest method
// won't allow port proxying on path-based apps if the app is a number.
AppSlugOrPort : chi . URLParam ( r , "workspaceapp" ) ,
} ,
AppPath : chiPath ,
AppQuery : r . URL . RawQuery ,
2023-04-05 18:41:55 +00:00
} )
if ! ok {
return
}
2024-04-26 15:52:53 +00:00
s . proxyWorkspaceApp ( rw , r , * token , chiPath , appurl . ApplicationURL { } )
2023-04-05 18:41:55 +00:00
}
2023-04-17 19:57:21 +00:00
// HandleSubdomain handles subdomain-based application proxy requests (aka.
2023-04-05 18:41:55 +00:00
// DevURLs in Coder V1).
//
// There are a lot of paths here:
// 1. If api.Hostname is not set then we pass on.
// 2. If we can't read the request hostname then we return a 400.
// 3. If the request hostname matches api.AccessURL then we pass on.
// 5. We split the subdomain into the subdomain and the "rest". If there are no
// periods in the hostname then we pass on.
2024-01-17 16:41:42 +00:00
// 5. We parse the subdomain into a appurl.ApplicationURL struct. If we
2023-04-05 18:41:55 +00:00
// encounter an error:
// a. If the "rest" does not match api.Hostname then we pass on;
// b. Otherwise, we return a 400.
// 6. Finally, we verify that the "rest" matches api.Hostname, else we
// return a 404.
//
// Rationales for each of the above steps:
// 1. We pass on if api.Hostname is not set to avoid returning any errors if
// `--app-hostname` is not configured.
// 2. Every request should have a valid Host header anyways.
// 3. We pass on if the request hostname matches api.AccessURL so we can
// support having the access URL be at the same level as the application
// base hostname.
// 4. We pass on if there are no periods in the hostname as application URLs
// must be a subdomain of a hostname, which implies there must be at least
// one period.
// 5. a. If the request subdomain is not a valid application URL, and the
// "rest" does not match api.Hostname, then it is very unlikely that
// the request was intended for this handler. We pass on.
// b. If the request subdomain is not a valid application URL, but the
// "rest" matches api.Hostname, then we return a 400 because the
// request is probably a typo or something.
// 6. We finally verify that the "rest" matches api.Hostname for security
// purposes regarding re-authentication and application proxy session
// tokens.
2023-04-17 19:57:21 +00:00
func ( s * Server ) HandleSubdomain ( middlewares ... func ( http . Handler ) http . Handler ) func ( http . Handler ) http . Handler {
2023-04-05 18:41:55 +00:00
return func ( next http . Handler ) http . Handler {
return http . HandlerFunc ( func ( rw http . ResponseWriter , r * http . Request ) {
ctx := r . Context ( )
// Step 1: Pass on if subdomain-based application proxying is not
// configured.
if s . Hostname == "" || s . HostnameRegex == nil {
next . ServeHTTP ( rw , r )
return
}
// Step 2: Get the request Host.
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 in tests (no idea how),
// which causes this path to get hit.
next . ServeHTTP ( rw , r )
return
}
httpapi . Write ( ctx , rw , http . StatusBadRequest , codersdk . Response {
Message : "Could not determine request Host." ,
} )
return
}
// Steps 3-6: Parse application from subdomain.
app , ok := s . parseHostname ( rw , r , next , host )
if ! ok {
return
}
2023-06-07 19:08:14 +00:00
// Use the passed in app middlewares before checking authentication and
// passing to the proxy app.
mws := chi . Middlewares ( append ( middlewares , httpmw . WorkspaceAppCors ( s . HostnameRegex , app ) ) )
2023-04-05 18:41:55 +00:00
mws . Handler ( http . HandlerFunc ( func ( rw http . ResponseWriter , r * http . Request ) {
2023-06-07 19:08:14 +00:00
if ! s . handleAPIKeySmuggling ( rw , r , AccessMethodSubdomain ) {
return
}
token , ok := ResolveRequest ( rw , r , ResolveRequestOptions {
Logger : s . Logger ,
SignedTokenProvider : s . SignedTokenProvider ,
DashboardURL : s . DashboardURL ,
PathAppBaseURL : s . AccessURL ,
AppHostname : s . Hostname ,
AppRequest : Request {
AccessMethod : AccessMethodSubdomain ,
BasePath : "/" ,
2023-10-10 20:02:39 +00:00
Prefix : app . Prefix ,
2023-06-07 19:08:14 +00:00
UsernameOrID : app . Username ,
WorkspaceNameOrID : app . WorkspaceName ,
AgentNameOrID : app . AgentName ,
AppSlugOrPort : app . AppSlugOrPort ,
} ,
AppPath : r . URL . Path ,
AppQuery : r . URL . RawQuery ,
} )
if ! ok {
return
}
2024-04-26 15:52:53 +00:00
s . proxyWorkspaceApp ( rw , r , * token , r . URL . Path , app )
2023-04-05 18:41:55 +00:00
} ) ) . ServeHTTP ( rw , r . WithContext ( ctx ) )
} )
}
}
// parseHostname will return if a given request is attempting to access a
// workspace app via a subdomain. If it is, the hostname of the request is parsed
2024-01-17 16:41:42 +00:00
// into an appurl.ApplicationURL and true is returned. If the request is not
2023-04-05 18:41:55 +00:00
// accessing a workspace app, then the next handler is called and false is
// returned.
2024-01-17 16:41:42 +00:00
func ( s * Server ) parseHostname ( rw http . ResponseWriter , r * http . Request , next http . Handler , host string ) ( appurl . ApplicationURL , bool ) {
2023-04-05 18:41:55 +00:00
// Check if the hostname matches either of the access URLs. If it does, the
// user was definitely trying to connect to the dashboard/API or a
// path-based app.
2024-01-17 16:41:42 +00:00
if appurl . HostnamesMatch ( s . DashboardURL . Hostname ( ) , host ) || appurl . HostnamesMatch ( s . AccessURL . Hostname ( ) , host ) {
2023-04-05 18:41:55 +00:00
next . ServeHTTP ( rw , r )
2024-01-17 16:41:42 +00:00
return appurl . ApplicationURL { } , false
2023-04-05 18:41:55 +00:00
}
// If there are no periods in the hostname, then it can't be a valid
// application URL.
if ! strings . Contains ( host , "." ) {
next . ServeHTTP ( rw , r )
2024-01-17 16:41:42 +00:00
return appurl . ApplicationURL { } , false
2023-04-05 18:41:55 +00:00
}
// Split the subdomain so we can parse the application details and verify it
// matches the configured app hostname later.
2024-01-17 16:41:42 +00:00
subdomain , ok := appurl . ExecuteHostnamePattern ( s . HostnameRegex , host )
2023-04-05 18:41:55 +00:00
if ! ok {
// Doesn't match the regex, so it's not a valid application URL.
next . ServeHTTP ( rw , r )
2024-01-17 16:41:42 +00:00
return appurl . ApplicationURL { } , false
2023-04-05 18:41:55 +00:00
}
// Check if the request is part of the deprecated logout flow. If so, we
// just redirect to the main access URL.
if subdomain == appLogoutHostname {
2023-04-17 19:57:21 +00:00
http . Redirect ( rw , r , s . AccessURL . String ( ) , http . StatusSeeOther )
2024-01-17 16:41:42 +00:00
return appurl . ApplicationURL { } , false
2023-04-05 18:41:55 +00:00
}
// Parse the application URL from the subdomain.
2024-01-17 16:41:42 +00:00
app , err := appurl . ParseSubdomainAppURL ( subdomain )
2023-04-05 18:41:55 +00:00
if err != nil {
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 : s . DashboardURL . String ( ) ,
} )
2024-01-17 16:41:42 +00:00
return appurl . ApplicationURL { } , false
2023-04-05 18:41:55 +00:00
}
return app , true
}
2024-04-26 15:52:53 +00:00
func ( s * Server ) proxyWorkspaceApp ( rw http . ResponseWriter , r * http . Request , appToken SignedToken , path string , app appurl . ApplicationURL ) {
2023-04-05 18:41:55 +00:00
ctx := r . Context ( )
// Filter IP headers from untrusted origins.
httpmw . FilterUntrustedOriginHeaders ( s . RealIPConfig , r )
// Ensure proper IP headers get sent to the forwarded application.
err := httpmw . EnsureXForwardedForHeader ( r )
if err != nil {
httpapi . InternalServerError ( rw , err )
return
}
appURL , err := url . Parse ( appToken . AppURL )
if err != nil {
site . RenderStaticErrorPage ( rw , r , site . ErrorPageData {
Status : http . StatusBadRequest ,
Title : "Bad Request" ,
Description : fmt . Sprintf ( "Application has an invalid URL %q: %s" , appToken . AppURL , err . Error ( ) ) ,
RetryEnabled : true ,
DashboardURL : s . DashboardURL . String ( ) ,
} )
return
}
// Verify that the port is allowed. See the docs above
// `codersdk.MinimumListeningPort` for more details.
port := appURL . Port ( )
if port != "" {
portInt , err := strconv . Atoi ( port )
if err != nil {
httpapi . Write ( ctx , rw , http . StatusBadRequest , codersdk . Response {
Message : fmt . Sprintf ( "App URL %q has an invalid port %q." , appToken . AppURL , port ) ,
Detail : err . Error ( ) ,
} )
return
}
2024-03-26 17:44:31 +00:00
if portInt < workspacesdk . AgentMinimumListeningPort {
2023-04-05 18:41:55 +00:00
httpapi . Write ( ctx , rw , http . StatusBadRequest , codersdk . Response {
2024-03-26 17:44:31 +00:00
Message : fmt . Sprintf ( "Application port %d is not permitted. Coder reserves ports less than %d for internal use." ,
portInt , workspacesdk . AgentMinimumListeningPort ,
) ,
2023-04-05 18:41:55 +00:00
} )
return
}
}
// Ensure path and query parameter correctness.
if 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 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 = path
appURL . RawQuery = ""
2024-04-26 15:52:53 +00:00
_ , protocol , isPort := app . PortInfo ( )
if isPort {
appURL . Scheme = protocol
}
2023-04-05 18:41:55 +00:00
2024-04-26 15:52:53 +00:00
proxy := s . AgentProvider . ReverseProxy ( appURL , s . DashboardURL , appToken . AgentID , app , s . Hostname )
2023-04-05 18:41:55 +00:00
2023-06-21 21:41:27 +00:00
proxy . ModifyResponse = func ( r * http . Response ) error {
r . Header . Del ( httpmw . AccessControlAllowOriginHeader )
r . Header . Del ( httpmw . AccessControlAllowCredentialsHeader )
r . Header . Del ( httpmw . AccessControlAllowMethodsHeader )
r . Header . Del ( httpmw . AccessControlAllowHeadersHeader )
varies := r . Header . Values ( httpmw . VaryHeader )
r . Header . Del ( httpmw . VaryHeader )
forbiddenVary := [ ] string {
httpmw . OriginHeader ,
httpmw . AccessControlRequestMethodsHeader ,
httpmw . AccessControlRequestHeadersHeader ,
}
for _ , value := range varies {
if ! slice . ContainsCompare ( forbiddenVary , value , strings . EqualFold ) {
r . Header . Add ( httpmw . VaryHeader , value )
}
}
return nil
}
2023-04-05 18:41:55 +00:00
// 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 ) )
}
// Convert canonicalized headers to their non-canonicalized counterparts.
// See the comment on `nonCanonicalHeaders` for more information on why this
// is necessary.
for k , v := range r . Header {
if n , ok := nonCanonicalHeaders [ k ] ; ok {
r . Header . Del ( k )
r . Header [ n ] = v
}
}
// end span so we don't get long lived trace data
tracing . EndHTTPSpan ( r , http . StatusOK , trace . SpanFromContext ( ctx ) )
2023-08-16 12:22:00 +00:00
report := newStatsReportFromSignedToken ( appToken )
s . collectStats ( report )
defer func ( ) {
// We must use defer here because ServeHTTP may panic.
2023-09-01 16:50:12 +00:00
report . SessionEndedAt = dbtime . Now ( )
2023-08-16 12:22:00 +00:00
s . collectStats ( report )
} ( )
2023-04-05 18:41:55 +00:00
proxy . ServeHTTP ( rw , r )
}
// workspaceAgentPTY spawns a PTY and pipes it over a WebSocket.
// This is used for the web terminal.
//
// @Summary Open PTY to workspace agent
// @ID open-pty-to-workspace-agent
// @Security CoderSessionToken
// @Tags Agents
// @Param workspaceagent path string true "Workspace agent ID" format(uuid)
// @Success 101
// @Router /workspaceagents/{workspaceagent}/pty [get]
func ( s * Server ) workspaceAgentPTY ( rw http . ResponseWriter , r * http . Request ) {
ctx := r . Context ( )
s . websocketWaitMutex . Lock ( )
s . websocketWaitGroup . Add ( 1 )
s . websocketWaitMutex . Unlock ( )
defer s . websocketWaitGroup . Done ( )
2023-04-17 19:57:21 +00:00
appToken , ok := ResolveRequest ( rw , r , ResolveRequestOptions {
Logger : s . Logger ,
SignedTokenProvider : s . SignedTokenProvider ,
DashboardURL : s . DashboardURL ,
PathAppBaseURL : s . AccessURL ,
AppHostname : s . Hostname ,
AppRequest : Request {
AccessMethod : AccessMethodTerminal ,
BasePath : r . URL . Path ,
AgentNameOrID : chi . URLParam ( r , "workspaceagent" ) ,
} ,
2023-04-20 23:59:45 +00:00
AppPath : "" ,
2023-04-17 19:57:21 +00:00
AppQuery : "" ,
2023-04-05 18:41:55 +00:00
} )
if ! ok {
return
}
2023-04-27 09:59:01 +00:00
log := s . Logger . With ( slog . F ( "agent_id" , appToken . AgentID ) )
log . Debug ( ctx , "resolved PTY request" )
2023-04-05 18:41:55 +00:00
values := r . URL . Query ( )
parser := httpapi . NewQueryParamParser ( )
2024-02-20 23:58:43 +00:00
reconnect := parser . RequiredNotEmpty ( "reconnect" ) . UUID ( values , uuid . New ( ) , "reconnect" )
2023-04-05 18:41:55 +00:00
height := parser . UInt ( values , 80 , "height" )
width := parser . UInt ( values , 80 , "width" )
if len ( parser . Errors ) > 0 {
httpapi . Write ( ctx , rw , http . StatusBadRequest , codersdk . Response {
Message : "Invalid query parameters." ,
Validations : parser . Errors ,
} )
return
}
conn , err := websocket . Accept ( rw , r , & websocket . AcceptOptions {
CompressionMode : websocket . CompressionDisabled ,
2023-04-28 21:04:52 +00:00
// Always allow websockets from the primary dashboard URL.
// Terminals are opened there and connect to the proxy.
OriginPatterns : [ ] string {
s . DashboardURL . Host ,
s . AccessURL . Host ,
} ,
2023-04-05 18:41:55 +00:00
} )
if err != nil {
httpapi . Write ( ctx , rw , http . StatusBadRequest , codersdk . Response {
Message : "Failed to accept websocket." ,
Detail : err . Error ( ) ,
} )
return
}
ctx , wsNetConn := WebsocketNetConn ( ctx , conn , websocket . MessageBinary )
defer wsNetConn . Close ( ) // Also closes conn.
go httpapi . Heartbeat ( ctx , conn )
2023-07-12 22:37:31 +00:00
agentConn , release , err := s . AgentProvider . AgentConn ( ctx , appToken . AgentID )
2023-04-05 18:41:55 +00:00
if err != nil {
2023-04-27 09:59:01 +00:00
log . Debug ( ctx , "dial workspace agent" , slog . Error ( err ) )
2023-04-05 18:41:55 +00:00
_ = conn . Close ( websocket . StatusInternalError , httpapi . WebsocketCloseSprintf ( "dial workspace agent: %s" , err ) )
return
}
defer release ( )
2023-04-27 09:59:01 +00:00
log . Debug ( ctx , "dialed workspace agent" )
2023-04-05 18:41:55 +00:00
ptNetConn , err := agentConn . ReconnectingPTY ( ctx , reconnect , uint16 ( height ) , uint16 ( width ) , r . URL . Query ( ) . Get ( "command" ) )
if err != nil {
2023-04-27 09:59:01 +00:00
log . Debug ( ctx , "dial reconnecting pty server in workspace agent" , slog . Error ( err ) )
2023-04-05 18:41:55 +00:00
_ = conn . Close ( websocket . StatusInternalError , httpapi . WebsocketCloseSprintf ( "dial: %s" , err ) )
return
}
defer ptNetConn . Close ( )
2023-04-27 09:59:01 +00:00
log . Debug ( ctx , "obtained PTY" )
2023-08-16 12:22:00 +00:00
report := newStatsReportFromSignedToken ( * appToken )
s . collectStats ( report )
defer func ( ) {
2023-09-01 16:50:12 +00:00
report . SessionEndedAt = dbtime . Now ( )
2023-08-16 12:22:00 +00:00
s . collectStats ( report )
} ( )
2023-04-06 16:39:22 +00:00
agentssh . Bicopy ( ctx , wsNetConn , ptNetConn )
2023-04-27 09:59:01 +00:00
log . Debug ( ctx , "pty Bicopy finished" )
2023-04-05 18:41:55 +00:00
}
2023-08-16 12:22:00 +00:00
func ( s * Server ) collectStats ( stats StatsReport ) {
if s . StatsCollector != nil {
s . StatsCollector . Collect ( stats )
}
}
2023-04-05 18:41:55 +00:00
// wsNetConn wraps net.Conn created by websocket.NetConn(). Cancel func
// is called if a read or write error is encountered.
type wsNetConn struct {
cancel context . CancelFunc
net . Conn
}
func ( c * wsNetConn ) Read ( b [ ] byte ) ( n int , err error ) {
n , err = c . Conn . Read ( b )
if err != nil {
c . cancel ( )
}
return n , err
}
func ( c * wsNetConn ) Write ( b [ ] byte ) ( n int , err error ) {
n , err = c . Conn . Write ( b )
if err != nil {
c . cancel ( )
}
return n , err
}
func ( c * wsNetConn ) Close ( ) error {
defer c . cancel ( )
return c . Conn . Close ( )
}
// WebsocketNetConn wraps websocket.NetConn and returns a context that
// is tied to the parent context and the lifetime of the conn. Any error
// during read or write will cancel the context, but not close the
// conn. Close should be called to release context resources.
func WebsocketNetConn ( ctx context . Context , conn * websocket . Conn , msgType websocket . MessageType ) ( context . Context , net . Conn ) {
ctx , cancel := context . WithCancel ( ctx )
nc := websocket . NetConn ( ctx , conn , msgType )
return ctx , & wsNetConn {
cancel : cancel ,
Conn : nc ,
}
}