mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
When `DisableUserSecretFilePath` is set, coderd strips file targets while building the agent manifest: env-only secrets are unchanged, dual-target secrets keep the env var with an empty file path, and file-only secrets are omitted so their plaintext never crosses DRPC. Stored paths stay in the database and reappear on the next manifest fetch once the option is cleared. Agents converge on reconnect; no protobuf, provisioner, or agent-side changes are needed, and files already written to workspace disks are left alone. Also exposes the positive capability as the `user-secret-file-path-enabled` embedded metadata tag, which the next PR in the stack consumes in the dashboard. Builds on #28518. Refs #27488. Reviewed and updated by Coder Agents on behalf of @dylanhuff-at-coder.
318 lines
10 KiB
Go
318 lines
10 KiB
Go
package agentapi
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/sync/errgroup"
|
|
"golang.org/x/xerrors"
|
|
"google.golang.org/protobuf/types/known/durationpb"
|
|
"tailscale.com/tailcfg"
|
|
|
|
agentproto "github.com/coder/coder/v2/agent/proto"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/externalauth"
|
|
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/tailnet"
|
|
)
|
|
|
|
type ManifestAPI struct {
|
|
AccessURL *url.URL
|
|
AppHostname string
|
|
ExternalAuthConfigs []*externalauth.Config
|
|
DisableDirectConnections bool
|
|
DerpForceWebSockets bool
|
|
DisableUserSecretFilePath bool
|
|
WorkspaceID uuid.UUID
|
|
|
|
AgentFn func(ctx context.Context) (database.WorkspaceAgent, error)
|
|
Database database.Store
|
|
DerpMapFn func() *tailcfg.DERPMap
|
|
}
|
|
|
|
func (a *ManifestAPI) GetManifest(ctx context.Context, _ *agentproto.GetManifestRequest) (*agentproto.Manifest, error) {
|
|
var (
|
|
dbApps []database.WorkspaceApp
|
|
scripts []database.GetWorkspaceAgentScriptsByAgentIDsRow
|
|
metadata []database.WorkspaceAgentMetadatum
|
|
workspace database.Workspace
|
|
devcontainers []database.WorkspaceAgentDevcontainer
|
|
)
|
|
|
|
workspaceAgent, err := a.AgentFn(ctx)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("getting workspace agent: %w", err)
|
|
}
|
|
|
|
var eg errgroup.Group
|
|
eg.Go(func() (err error) {
|
|
dbApps, err = a.Database.GetWorkspaceAppsByAgentID(ctx, workspaceAgent.ID)
|
|
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
eg.Go(func() (err error) {
|
|
// nolint:gocritic // This is necessary to fetch agent scripts!
|
|
scripts, err = a.Database.GetWorkspaceAgentScriptsByAgentIDs(dbauthz.AsSystemRestricted(ctx), []uuid.UUID{workspaceAgent.ID})
|
|
return err
|
|
})
|
|
eg.Go(func() (err error) {
|
|
metadata, err = a.Database.GetWorkspaceAgentMetadata(ctx, database.GetWorkspaceAgentMetadataParams{
|
|
WorkspaceAgentID: workspaceAgent.ID,
|
|
Keys: nil, // all
|
|
})
|
|
return err
|
|
})
|
|
eg.Go(func() (err error) {
|
|
workspace, err = a.Database.GetWorkspaceByID(ctx, a.WorkspaceID)
|
|
if err != nil {
|
|
return xerrors.Errorf("getting workspace by id: %w", err)
|
|
}
|
|
return err
|
|
})
|
|
eg.Go(func() (err error) {
|
|
devcontainers, err = a.Database.GetWorkspaceAgentDevcontainersByAgentID(ctx, workspaceAgent.ID)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
err = eg.Wait()
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("fetching workspace agent data: %w", err)
|
|
}
|
|
|
|
// Fetch user secrets for injection into the agent manifest.
|
|
// This runs after the errgroup because it needs workspace.OwnerID.
|
|
//nolint:gocritic // System context needed to read secrets for the workspace owner.
|
|
userSecrets, err := a.Database.ListUserSecretsWithValues(dbauthz.AsSystemRestricted(ctx), workspace.OwnerID)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("getting user secrets: %w", err)
|
|
}
|
|
|
|
appSlug := appurl.ApplicationURL{
|
|
AppSlugOrPort: "{{port}}",
|
|
AgentName: workspaceAgent.Name,
|
|
WorkspaceName: workspace.Name,
|
|
Username: workspace.OwnerUsername,
|
|
}
|
|
|
|
vscodeProxyURI := vscodeProxyURI(appSlug, a.AccessURL, a.AppHostname)
|
|
|
|
envs, err := db2sdk.WorkspaceAgentEnvironment(workspaceAgent)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var gitAuthConfigs uint32
|
|
for _, cfg := range a.ExternalAuthConfigs {
|
|
if codersdk.EnhancedExternalAuthProvider(cfg.Type).Git() {
|
|
gitAuthConfigs++
|
|
}
|
|
}
|
|
|
|
apps, err := dbAppsToProto(dbApps, workspaceAgent, workspace.OwnerUsername, workspace, a.AppHostname)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("converting workspace apps: %w", err)
|
|
}
|
|
|
|
var parentID []byte
|
|
if workspaceAgent.ParentID.Valid {
|
|
parentID = workspaceAgent.ParentID.UUID[:]
|
|
}
|
|
|
|
secretFilePathPolicy := userSecretFilePathAllowed
|
|
if a.DisableUserSecretFilePath {
|
|
secretFilePathPolicy = userSecretFilePathBlocked
|
|
}
|
|
|
|
return &agentproto.Manifest{
|
|
AgentId: workspaceAgent.ID[:],
|
|
AgentName: workspaceAgent.Name,
|
|
OwnerUsername: workspace.OwnerUsername,
|
|
WorkspaceId: workspace.ID[:],
|
|
WorkspaceName: workspace.Name,
|
|
GitAuthConfigs: gitAuthConfigs,
|
|
EnvironmentVariables: envs,
|
|
Directory: workspaceAgent.Directory,
|
|
VsCodePortProxyUri: vscodeProxyURI,
|
|
MotdPath: workspaceAgent.MOTDFile,
|
|
DisableDirectConnections: a.DisableDirectConnections,
|
|
DerpForceWebsockets: a.DerpForceWebSockets,
|
|
ParentId: parentID,
|
|
|
|
DerpMap: tailnet.DERPMapToProto(a.DerpMapFn()),
|
|
Scripts: dbAgentScriptsToProto(scripts),
|
|
Apps: apps,
|
|
Metadata: dbAgentMetadataToProtoDescription(metadata),
|
|
Devcontainers: dbAgentDevcontainersToProto(devcontainers),
|
|
Secrets: dbUserSecretsToProto(userSecrets, secretFilePathPolicy),
|
|
}, nil
|
|
}
|
|
|
|
func vscodeProxyURI(app appurl.ApplicationURL, accessURL *url.URL, appHost string) string {
|
|
// Proxying by port only works for subdomains. If subdomain support is not
|
|
// available, return an empty string.
|
|
if appHost == "" {
|
|
return ""
|
|
}
|
|
|
|
// This will handle the ports from the accessURL or appHost.
|
|
appHost = appurl.SubdomainAppHost(appHost, accessURL)
|
|
// Return the url with a scheme and any wildcards replaced with the app slug.
|
|
return accessURL.Scheme + "://" + strings.ReplaceAll(appHost, "*", app.String())
|
|
}
|
|
|
|
func dbAgentMetadataToProtoDescription(metadata []database.WorkspaceAgentMetadatum) []*agentproto.WorkspaceAgentMetadata_Description {
|
|
ret := make([]*agentproto.WorkspaceAgentMetadata_Description, len(metadata))
|
|
for i, metadatum := range metadata {
|
|
ret[i] = dbAgentMetadatumToProtoDescription(metadatum)
|
|
}
|
|
return ret
|
|
}
|
|
|
|
func dbAgentMetadatumToProtoDescription(metadatum database.WorkspaceAgentMetadatum) *agentproto.WorkspaceAgentMetadata_Description {
|
|
return &agentproto.WorkspaceAgentMetadata_Description{
|
|
DisplayName: metadatum.DisplayName,
|
|
Key: metadatum.Key,
|
|
Script: metadatum.Script,
|
|
Interval: durationpb.New(time.Duration(metadatum.Interval)),
|
|
Timeout: durationpb.New(time.Duration(metadatum.Timeout)),
|
|
}
|
|
}
|
|
|
|
func dbAgentScriptsToProto(scripts []database.GetWorkspaceAgentScriptsByAgentIDsRow) []*agentproto.WorkspaceAgentScript {
|
|
ret := make([]*agentproto.WorkspaceAgentScript, len(scripts))
|
|
for i, script := range scripts {
|
|
ret[i] = dbAgentScriptToProto(script)
|
|
}
|
|
return ret
|
|
}
|
|
|
|
func dbAgentScriptToProto(script database.GetWorkspaceAgentScriptsByAgentIDsRow) *agentproto.WorkspaceAgentScript {
|
|
return &agentproto.WorkspaceAgentScript{
|
|
Id: script.ID[:],
|
|
LogSourceId: script.LogSourceID[:],
|
|
LogPath: script.LogPath,
|
|
Script: script.Script,
|
|
Cron: script.Cron,
|
|
RunOnStart: script.RunOnStart,
|
|
RunOnStop: script.RunOnStop,
|
|
StartBlocksLogin: script.StartBlocksLogin,
|
|
Timeout: durationpb.New(time.Duration(script.TimeoutSeconds) * time.Second),
|
|
}
|
|
}
|
|
|
|
func dbAppsToProto(dbApps []database.WorkspaceApp, agent database.WorkspaceAgent, ownerName string, workspace database.Workspace, appHostname string) ([]*agentproto.WorkspaceApp, error) {
|
|
ret := make([]*agentproto.WorkspaceApp, len(dbApps))
|
|
for i, dbApp := range dbApps {
|
|
var err error
|
|
ret[i], err = dbAppToProto(dbApp, agent, ownerName, workspace, appHostname)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("parse app %v (%q): %w", i, dbApp.Slug, err)
|
|
}
|
|
}
|
|
return ret, nil
|
|
}
|
|
|
|
func dbAppToProto(dbApp database.WorkspaceApp, agent database.WorkspaceAgent, ownerName string, workspace database.Workspace, appHostname string) (*agentproto.WorkspaceApp, error) {
|
|
sharingLevelRaw, ok := agentproto.WorkspaceApp_SharingLevel_value[strings.ToUpper(string(dbApp.SharingLevel))]
|
|
if !ok {
|
|
return nil, xerrors.Errorf("unknown app sharing level: %q", dbApp.SharingLevel)
|
|
}
|
|
|
|
healthRaw, ok := agentproto.WorkspaceApp_Health_value[strings.ToUpper(string(dbApp.Health))]
|
|
if !ok {
|
|
return nil, xerrors.Errorf("unknown app health: %q", dbApp.SharingLevel)
|
|
}
|
|
|
|
// SubdomainName should be empty if AppHostname is not configured
|
|
subdomainName := ""
|
|
if appHostname != "" {
|
|
subdomainName = db2sdk.AppSubdomain(dbApp, agent.Name, workspace.Name, ownerName)
|
|
}
|
|
|
|
return &agentproto.WorkspaceApp{
|
|
Id: dbApp.ID[:],
|
|
Url: dbApp.Url.String,
|
|
External: dbApp.External,
|
|
Slug: dbApp.Slug,
|
|
DisplayName: dbApp.DisplayName,
|
|
Command: dbApp.Command.String,
|
|
Icon: dbApp.Icon,
|
|
Subdomain: dbApp.Subdomain,
|
|
SubdomainName: subdomainName,
|
|
SharingLevel: agentproto.WorkspaceApp_SharingLevel(sharingLevelRaw),
|
|
Healthcheck: &agentproto.WorkspaceApp_Healthcheck{
|
|
Url: dbApp.HealthcheckUrl,
|
|
Interval: durationpb.New(time.Duration(dbApp.HealthcheckInterval) * time.Second),
|
|
Threshold: dbApp.HealthcheckThreshold,
|
|
},
|
|
Health: agentproto.WorkspaceApp_Health(healthRaw),
|
|
Hidden: dbApp.Hidden,
|
|
}, nil
|
|
}
|
|
|
|
func dbAgentDevcontainersToProto(devcontainers []database.WorkspaceAgentDevcontainer) []*agentproto.WorkspaceAgentDevcontainer {
|
|
ret := make([]*agentproto.WorkspaceAgentDevcontainer, len(devcontainers))
|
|
for i, dc := range devcontainers {
|
|
var subagentID []byte
|
|
if dc.SubagentID.Valid {
|
|
subagentID = dc.SubagentID.UUID[:]
|
|
}
|
|
|
|
ret[i] = &agentproto.WorkspaceAgentDevcontainer{
|
|
Id: dc.ID[:],
|
|
Name: dc.Name,
|
|
WorkspaceFolder: dc.WorkspaceFolder,
|
|
ConfigPath: dc.ConfigPath,
|
|
SubagentId: subagentID,
|
|
}
|
|
}
|
|
return ret
|
|
}
|
|
|
|
// userSecretFilePathPolicy is a named type rather than a bool parameter to
|
|
// satisfy revive's flag-parameter rule.
|
|
type userSecretFilePathPolicy int
|
|
|
|
const (
|
|
userSecretFilePathAllowed userSecretFilePathPolicy = iota
|
|
userSecretFilePathBlocked
|
|
)
|
|
|
|
func dbUserSecretsToProto(secrets []database.UserSecret, policy userSecretFilePathPolicy) []*agentproto.WorkspaceSecret {
|
|
ret := make([]*agentproto.WorkspaceSecret, 0, len(secrets))
|
|
for _, s := range secrets {
|
|
// Skip disabled secrets so they are not injected as env vars or
|
|
// written to secret files. The API guarantees every enabled
|
|
// secret has at least one of env_name or file_path set, so we
|
|
// don't need to filter both-empty rows separately here.
|
|
if !s.Enabled {
|
|
continue
|
|
}
|
|
filePath := s.FilePath
|
|
if policy == userSecretFilePathBlocked {
|
|
if s.EnvName == "" {
|
|
continue
|
|
}
|
|
filePath = ""
|
|
}
|
|
ret = append(ret, &agentproto.WorkspaceSecret{
|
|
EnvName: s.EnvName,
|
|
FilePath: filePath,
|
|
Value: []byte(s.Value),
|
|
})
|
|
}
|
|
return ret
|
|
}
|