coder/coderd/workspaceapps/request.go

461 lines
14 KiB
Go

package workspaceapps
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"golang.org/x/xerrors"
"github.com/google/uuid"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/codersdk"
)
var errWorkspaceStopped = xerrors.New("stopped workspace")
type AccessMethod string
const (
AccessMethodPath AccessMethod = "path"
AccessMethodSubdomain AccessMethod = "subdomain"
// AccessMethodTerminal is special since it's not a real app and only
// applies to the PTY endpoint on the API.
AccessMethodTerminal AccessMethod = "terminal"
)
type IssueTokenRequest struct {
AppRequest Request `json:"app_request"`
// PathAppBaseURL is required.
PathAppBaseURL string `json:"path_app_base_url"`
// AppHostname is the optional hostname for subdomain apps on the external
// proxy. It must start with an asterisk.
AppHostname string `json:"app_hostname"`
// AppPath is the path of the user underneath the app base path.
AppPath string `json:"app_path"`
// AppQuery is the query parameters the user provided in the app request.
AppQuery string `json:"app_query"`
// SessionToken is the session token provided by the user.
SessionToken string `json:"session_token"`
}
// AppBaseURL returns the base URL of this specific app request. An error is
// returned if a subdomain app hostname is not provided but the app is a
// subdomain app.
func (r IssueTokenRequest) AppBaseURL() (*url.URL, error) {
u, err := url.Parse(r.PathAppBaseURL)
if err != nil {
return nil, xerrors.Errorf("parse path app base URL: %w", err)
}
switch r.AppRequest.AccessMethod {
case AccessMethodPath, AccessMethodTerminal:
u.Path = r.AppRequest.BasePath
if !strings.HasSuffix(u.Path, "/") {
u.Path += "/"
}
return u, nil
case AccessMethodSubdomain:
if r.AppHostname == "" {
return nil, xerrors.New("subdomain app hostname is required to generate subdomain app URL")
}
appHost := appurl.ApplicationURL{
Prefix: r.AppRequest.Prefix,
AppSlugOrPort: r.AppRequest.AppSlugOrPort,
AgentName: r.AppRequest.AgentNameOrID,
WorkspaceName: r.AppRequest.WorkspaceNameOrID,
Username: r.AppRequest.UsernameOrID,
}
u.Host = strings.Replace(r.AppHostname, "*", appHost.String(), 1)
u.Path = r.AppRequest.BasePath
return u, nil
default:
return nil, xerrors.Errorf("invalid access method: %q", r.AppRequest.AccessMethod)
}
}
type Request struct {
AccessMethod AccessMethod `json:"access_method"`
// BasePath of the app. For path apps, this is the path prefix in the router
// for this particular app. For subdomain apps, this should be "/". This is
// used for setting the cookie path.
BasePath string `json:"base_path"`
// Prefix is the prefix of the subdomain app URL. Prefix should have a
// trailing "---" if set.
Prefix string `json:"app_prefix"`
// For the following fields, if the AccessMethod is AccessMethodTerminal,
// then only AgentNameOrID may be set and it must be a UUID. The other
// fields must be left blank.
UsernameOrID string `json:"username_or_id"`
// WorkspaceAndAgent xor WorkspaceNameOrID are required.
WorkspaceAndAgent string `json:"-"` // "workspace" or "workspace.agent"
WorkspaceNameOrID string `json:"workspace_name_or_id"`
// AgentNameOrID is not required if the workspace has only one agent.
AgentNameOrID string `json:"agent_name_or_id"`
AppSlugOrPort string `json:"app_slug_or_port"`
}
// Normalize replaces WorkspaceAndAgent with WorkspaceNameOrID and
// AgentNameOrID. This must be called before Validate.
func (r Request) Normalize() Request {
req := r
if req.WorkspaceAndAgent != "" {
// workspace.agent
workspaceAndAgent := strings.SplitN(req.WorkspaceAndAgent, ".", 2)
req.WorkspaceAndAgent = ""
req.WorkspaceNameOrID = workspaceAndAgent[0]
if len(workspaceAndAgent) > 1 {
req.AgentNameOrID = workspaceAndAgent[1]
}
}
if !strings.HasSuffix(req.BasePath, "/") {
req.BasePath += "/"
}
return req
}
// Validate ensures the request is correct and contains the necessary
// parameters.
func (r Request) Validate() error {
switch r.AccessMethod {
case AccessMethodPath, AccessMethodSubdomain, AccessMethodTerminal:
default:
return xerrors.Errorf("invalid access method: %q", r.AccessMethod)
}
if r.BasePath == "" {
return xerrors.New("base path is required")
}
if r.WorkspaceAndAgent != "" {
return xerrors.New("dev error: appReq.Validate() called before appReq.Normalize()")
}
if r.AccessMethod == AccessMethodTerminal {
if r.UsernameOrID != "" || r.WorkspaceNameOrID != "" || r.AppSlugOrPort != "" {
return xerrors.New("dev error: cannot specify any fields other than r.AccessMethod, r.BasePath and r.AgentNameOrID for terminal access method")
}
if r.AgentNameOrID == "" {
return xerrors.New("agent name or ID is required")
}
if _, err := uuid.Parse(r.AgentNameOrID); err != nil {
return xerrors.Errorf("invalid agent name or ID %q, must be a UUID: %w", r.AgentNameOrID, err)
}
return nil
}
if r.UsernameOrID == "" {
return xerrors.New("username or ID is required")
}
if r.UsernameOrID == codersdk.Me {
// We block "me" for workspace app auth to avoid any security issues
// caused by having an identical workspace name on yourself and a
// different user and potentially reusing a token.
//
// This is also mitigated by storing the workspace/agent ID in the
// token, but we block it here to be double safe.
//
// Subdomain apps have never been used with "me" from our code, and path
// apps now have a redirect to remove the "me" from the URL.
return xerrors.New(`username cannot be "me" in app requests`)
}
if r.WorkspaceNameOrID == "" {
return xerrors.New("workspace name or ID is required")
}
if r.AppSlugOrPort == "" {
return xerrors.New("app slug or port is required")
}
if r.Prefix != "" && r.AccessMethod != AccessMethodSubdomain {
return xerrors.New("prefix is only valid for subdomain apps")
}
if r.Prefix != "" && !strings.HasSuffix(r.Prefix, "---") {
return xerrors.New("prefix must have a trailing '---'")
}
return nil
}
type databaseRequest struct {
Request
// User is the user that owns the app.
User database.User
// Workspace is the workspace that the app is in.
Workspace database.Workspace
// Agent is the agent that the app is running on.
Agent database.WorkspaceAgent
// AppURL is the resolved URL to the workspace app. This is only set for non
// terminal requests.
AppURL *url.URL
// AppSharingLevel is the sharing level of the app. This is forced to be set
// to AppSharingLevelOwner if the access method is terminal.
AppSharingLevel database.AppSharingLevel
}
// getDatabase does queries to get the owner user, workspace and agent
// associated with the app in the request. This will correctly perform the
// queries in the correct order based on the access method and what fields are
// available.
//
// If any of the queries don't return any rows, the error will wrap
// sql.ErrNoRows. All other errors should be considered internal server errors.
func (r Request) getDatabase(ctx context.Context, db database.Store) (*databaseRequest, error) {
// If the AccessMethod is AccessMethodTerminal, then we need to get the
// agent first since that's the only info we have.
if r.AccessMethod == AccessMethodTerminal {
return r.getDatabaseTerminal(ctx, db)
}
// For non-terminal requests, get the objects in order since we have all
// fields available.
// Get user.
var (
user database.User
userErr error
)
if userID, uuidErr := uuid.Parse(r.UsernameOrID); uuidErr == nil {
user, userErr = db.GetUserByID(ctx, userID)
} else {
user, userErr = db.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
Username: r.UsernameOrID,
})
}
if userErr != nil {
return nil, xerrors.Errorf("get user %q: %w", r.UsernameOrID, userErr)
}
// Get workspace.
var (
workspace database.Workspace
workspaceErr error
)
if workspaceID, uuidErr := uuid.Parse(r.WorkspaceNameOrID); uuidErr == nil {
workspace, workspaceErr = db.GetWorkspaceByID(ctx, workspaceID)
} else {
workspace, workspaceErr = db.GetWorkspaceByOwnerIDAndName(ctx, database.GetWorkspaceByOwnerIDAndNameParams{
OwnerID: user.ID,
Name: r.WorkspaceNameOrID,
Deleted: false,
})
}
if workspaceErr != nil {
return nil, xerrors.Errorf("get workspace %q: %w", r.WorkspaceNameOrID, workspaceErr)
}
// Get workspace agents.
agents, err := db.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, workspace.ID)
if err != nil {
return nil, xerrors.Errorf("get workspace agents: %w", err)
}
build, err := db.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
if err != nil {
return nil, xerrors.Errorf("get latest workspace build: %w", err)
}
if build.Transition == database.WorkspaceTransitionStop {
return nil, errWorkspaceStopped
}
if len(agents) == 0 {
// TODO(@deansheather): return a 404 if there are no agents in the
// workspace, requires a different error type.
return nil, xerrors.Errorf("no agents in workspace: %w", sql.ErrNoRows)
}
// Get workspace apps.
agentIDs := make([]uuid.UUID, len(agents))
for i, agent := range agents {
agentIDs[i] = agent.ID
}
apps, err := db.GetWorkspaceAppsByAgentIDs(ctx, agentIDs)
if err != nil {
return nil, xerrors.Errorf("get workspace apps: %w", err)
}
// Get the app first, because r.AgentNameOrID is optional depending on
// whether the app is a slug or a port and whether there are multiple agents
// in the workspace or not.
var (
agentNameOrID = r.AgentNameOrID
appURL string
appSharingLevel database.AppSharingLevel
portUint, portUintErr = strconv.ParseUint(r.AppSlugOrPort, 10, 16)
)
if portUintErr == nil {
if r.AccessMethod != AccessMethodSubdomain {
// TODO(@deansheather): this should return a 400 instead of a 500.
return nil, xerrors.New("port-based URLs are only supported for subdomain-based applications")
}
// If the user specified a port, then they must specify the agent if
// there are multiple agents in the workspace. App names are unique per
// workspace.
if agentNameOrID == "" {
if len(agents) != 1 {
return nil, xerrors.New("port specified with no agent, but multiple agents exist in the workspace")
}
agentNameOrID = agents[0].ID.String()
}
// If the app slug is a port number, then route to the port as an
// "anonymous app". We only support HTTP for port-based URLs.
//
// This is only supported for subdomain-based applications.
appURL = fmt.Sprintf("http://127.0.0.1:%d", portUint)
appSharingLevel = database.AppSharingLevelOwner
// Port sharing authorization
agentName := agentNameOrID
id, err := uuid.Parse(agentNameOrID)
for _, a := range agents {
// if err is nil then it's an UUID
if err == nil && a.ID == id {
agentName = a.Name
break
}
// otherwise it's a name
if a.Name == agentNameOrID {
break
}
}
// First check if there is a port share for the port
ps, err := db.GetWorkspaceAgentPortShare(ctx, database.GetWorkspaceAgentPortShareParams{
WorkspaceID: workspace.ID,
AgentName: agentName,
Port: int32(portUint),
})
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return nil, xerrors.Errorf("get workspace agent port share: %w", err)
}
// No port share found, so we keep default to owner.
} else {
appSharingLevel = ps.ShareLevel
}
} else {
for _, app := range apps {
if app.Slug == r.AppSlugOrPort {
if !app.Url.Valid {
return nil, xerrors.Errorf("app URL is not valid")
}
agentNameOrID = app.AgentID.String()
if app.SharingLevel != "" {
appSharingLevel = app.SharingLevel
} else {
appSharingLevel = database.AppSharingLevelOwner
}
appURL = app.Url.String
break
}
}
}
if appURL == "" {
return nil, xerrors.Errorf("no app found with slug %q: %w", r.AppSlugOrPort, sql.ErrNoRows)
}
// Finally, get agent.
var agent database.WorkspaceAgent
if agentID, uuidErr := uuid.Parse(agentNameOrID); uuidErr == nil {
for _, a := range agents {
if a.ID == agentID {
agent = a
break
}
}
} else {
if agentNameOrID == "" && len(agents) == 1 {
agent = agents[0]
} else {
for _, a := range agents {
if a.Name == agentNameOrID {
agent = a
break
}
}
}
if agent.ID == uuid.Nil {
return nil, xerrors.Errorf("no agent found with name %q: %w", r.AgentNameOrID, sql.ErrNoRows)
}
}
appURLParsed, err := url.Parse(appURL)
if err != nil {
return nil, xerrors.Errorf("parse app URL %q: %w", appURL, err)
}
return &databaseRequest{
Request: r,
User: user,
Workspace: workspace,
Agent: agent,
AppURL: appURLParsed,
AppSharingLevel: appSharingLevel,
}, nil
}
// getDatabaseTerminal is called by getDatabase for AccessMethodTerminal
// requests.
func (r Request) getDatabaseTerminal(ctx context.Context, db database.Store) (*databaseRequest, error) {
if r.AccessMethod != AccessMethodTerminal {
return nil, xerrors.Errorf("invalid access method %q for terminal request", r.AccessMethod)
}
agentID, uuidErr := uuid.Parse(r.AgentNameOrID)
if uuidErr != nil {
return nil, xerrors.Errorf("invalid agent name or ID %q, must be a UUID for terminal requests: %w", r.AgentNameOrID, uuidErr)
}
var err error
agent, err := db.GetWorkspaceAgentByID(ctx, agentID)
if err != nil {
return nil, xerrors.Errorf("get workspace agent %q: %w", agentID, err)
}
// Get the corresponding resource.
res, err := db.GetWorkspaceResourceByID(ctx, agent.ResourceID)
if err != nil {
return nil, xerrors.Errorf("get workspace agent resource %q: %w", agent.ResourceID, err)
}
// Get the corresponding workspace build.
build, err := db.GetWorkspaceBuildByJobID(ctx, res.JobID)
if err != nil {
return nil, xerrors.Errorf("get workspace build by job ID %q: %w", res.JobID, err)
}
// Get the corresponding workspace.
workspace, err := db.GetWorkspaceByID(ctx, build.WorkspaceID)
if err != nil {
return nil, xerrors.Errorf("get workspace %q: %w", build.WorkspaceID, err)
}
// Get the workspace's owner.
user, err := db.GetUserByID(ctx, workspace.OwnerID)
if err != nil {
return nil, xerrors.Errorf("get user %q: %w", workspace.OwnerID, err)
}
return &databaseRequest{
Request: r,
User: user,
Workspace: workspace,
Agent: agent,
AppURL: nil,
AppSharingLevel: database.AppSharingLevelOwner,
}, nil
}