Ctrl K
ComunidadGuías…

coderd/workspaceapps/request.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package workspaceapps

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"net/url"
	"strconv"
	"strings"

	"github.com/google/uuid"
	"golang.org/x/xerrors"

	"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
}

// Check ensures the request is correct and contains the necessary
// parameters.
func (r Request) Check() 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
	// UserID is the ID of the user that owns the app.
	UserID uuid.UUID
	// Workspace is the workspace that the app is in.
	Workspace database.WorkspaceTable
	// Agent is the agent that the app is running on.
	Agent database.WorkspaceAgent
	// App is the app that the user is trying to access.
	App database.WorkspaceApp

	// 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
	// CorsBehavior is set at the template level for all apps/ports in a workspace, and can
	// either be the current CORS middleware 'simple' or bypass the cors middleware with 'passthru'.
	CorsBehavior database.CorsBehavior
}

// 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)
		if workspaceErr == nil && workspace.OwnerID != user.ID {
			workspaceErr = sql.ErrNoRows
		}
	} 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
		app             database.WorkspaceApp
		appURL          string
		appSharingLevel database.AppSharingLevel
		// First check if it's a port-based URL with an optional "s" suffix for HTTPS.
		potentialPortStr      = strings.TrimSuffix(r.AppSlugOrPort, "s")
		portUint, portUintErr = strconv.ParseUint(potentialPortStr, 10, 16)
		corsBehavior          database.CorsBehavior
	)

	tmpl, err := db.GetTemplateByID(ctx, workspace.TemplateID)
	if err != nil {
		return nil, xerrors.Errorf("get template %q: %w", workspace.TemplateID, err)
	}
	corsBehavior = tmpl.CorsBehavior
	//nolint:nestif
	if portUintErr == nil {
		protocol := "http"
		if strings.HasSuffix(r.AppSlugOrPort, "s") {
			protocol = "https"
		}

		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".
		//
		// This is only supported for subdomain-based applications.
		appURL = fmt.Sprintf("%s://127.0.0.1:%d", protocol, 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 _, a := range apps {
			if a.Slug == r.AppSlugOrPort {
				app = a
				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,
		UserID:          user.ID,
		Workspace:       workspace.WorkspaceTable(),
		Agent:           agent,
		App:             app,
		AppURL:          appURLParsed,
		AppSharingLevel: appSharingLevel,
		CorsBehavior:    corsBehavior,
	}, 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
	aw, err := db.GetWorkspaceAgentAndWorkspaceByID(ctx, agentID)
	if err != nil {
		return nil, xerrors.Errorf("get workspace agent %q with workspace: %w", agentID, err)
	}

	return &databaseRequest{
		Request:         r,
		UserID:          aw.WorkspaceTable.OwnerID,
		Workspace:       aw.WorkspaceTable,
		Agent:           aw.WorkspaceAgent,
		AppURL:          nil,
		AppSharingLevel: database.AppSharingLevelOwner,
	}, nil
}