mirror of
https://github.com/coder/coder.git
synced 2025-07-21 01:28:49 +00:00
feat: add agentapi endpoint to report connections for audit (#16507)
This change adds a new `ReportConnection` endpoint to the `agentapi`. The protocol version was bumped previously, so it has been omitted here. This allows the agent to report connection events, for example when the user connects to the workspace via SSH or VS Code. Updates #15139
This commit is contained in:
committed by
GitHub
parent
dedc32fb1a
commit
b07b33ec9d
@ -372,7 +372,6 @@ func (a *agent) collectMetadata(ctx context.Context, md codersdk.WorkspaceAgentM
|
|||||||
// Important: if the command times out, we may see a misleading error like
|
// Important: if the command times out, we may see a misleading error like
|
||||||
// "exit status 1", so it's important to include the context error.
|
// "exit status 1", so it's important to include the context error.
|
||||||
err = errors.Join(err, ctx.Err())
|
err = errors.Join(err, ctx.Err())
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Error = fmt.Sprintf("run cmd: %+v", err)
|
result.Error = fmt.Sprintf("run cmd: %+v", err)
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,7 @@ import (
|
|||||||
"golang.org/x/exp/slices"
|
"golang.org/x/exp/slices"
|
||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
"google.golang.org/protobuf/types/known/durationpb"
|
"google.golang.org/protobuf/types/known/durationpb"
|
||||||
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
"storj.io/drpc/drpcmux"
|
"storj.io/drpc/drpcmux"
|
||||||
"storj.io/drpc/drpcserver"
|
"storj.io/drpc/drpcserver"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
@ -170,6 +171,7 @@ type FakeAgentAPI struct {
|
|||||||
lifecycleStates []codersdk.WorkspaceAgentLifecycle
|
lifecycleStates []codersdk.WorkspaceAgentLifecycle
|
||||||
metadata map[string]agentsdk.Metadata
|
metadata map[string]agentsdk.Metadata
|
||||||
timings []*agentproto.Timing
|
timings []*agentproto.Timing
|
||||||
|
connections []*agentproto.Connection
|
||||||
|
|
||||||
getAnnouncementBannersFunc func() ([]codersdk.BannerConfig, error)
|
getAnnouncementBannersFunc func() ([]codersdk.BannerConfig, error)
|
||||||
getResourcesMonitoringConfigurationFunc func() (*agentproto.GetResourcesMonitoringConfigurationResponse, error)
|
getResourcesMonitoringConfigurationFunc func() (*agentproto.GetResourcesMonitoringConfigurationResponse, error)
|
||||||
@ -338,12 +340,20 @@ func (f *FakeAgentAPI) BatchCreateLogs(ctx context.Context, req *agentproto.Batc
|
|||||||
|
|
||||||
func (f *FakeAgentAPI) ScriptCompleted(_ context.Context, req *agentproto.WorkspaceAgentScriptCompletedRequest) (*agentproto.WorkspaceAgentScriptCompletedResponse, error) {
|
func (f *FakeAgentAPI) ScriptCompleted(_ context.Context, req *agentproto.WorkspaceAgentScriptCompletedRequest) (*agentproto.WorkspaceAgentScriptCompletedResponse, error) {
|
||||||
f.Lock()
|
f.Lock()
|
||||||
f.timings = append(f.timings, req.Timing)
|
f.timings = append(f.timings, req.GetTiming())
|
||||||
f.Unlock()
|
f.Unlock()
|
||||||
|
|
||||||
return &agentproto.WorkspaceAgentScriptCompletedResponse{}, nil
|
return &agentproto.WorkspaceAgentScriptCompletedResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *FakeAgentAPI) ReportConnection(_ context.Context, req *agentproto.ReportConnectionRequest) (*emptypb.Empty, error) {
|
||||||
|
f.Lock()
|
||||||
|
f.connections = append(f.connections, req.GetConnection())
|
||||||
|
f.Unlock()
|
||||||
|
|
||||||
|
return &emptypb.Empty{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func NewFakeAgentAPI(t testing.TB, logger slog.Logger, manifest *agentproto.Manifest, statsCh chan *agentproto.Stats) *FakeAgentAPI {
|
func NewFakeAgentAPI(t testing.TB, logger slog.Logger, manifest *agentproto.Manifest, statsCh chan *agentproto.Stats) *FakeAgentAPI {
|
||||||
return &FakeAgentAPI{
|
return &FakeAgentAPI{
|
||||||
t: t,
|
t: t,
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,7 @@ package coder.agent.v2;
|
|||||||
import "tailnet/proto/tailnet.proto";
|
import "tailnet/proto/tailnet.proto";
|
||||||
import "google/protobuf/timestamp.proto";
|
import "google/protobuf/timestamp.proto";
|
||||||
import "google/protobuf/duration.proto";
|
import "google/protobuf/duration.proto";
|
||||||
|
import "google/protobuf/empty.proto";
|
||||||
|
|
||||||
message WorkspaceApp {
|
message WorkspaceApp {
|
||||||
bytes id = 1;
|
bytes id = 1;
|
||||||
@ -340,6 +341,33 @@ message PushResourcesMonitoringUsageRequest {
|
|||||||
message PushResourcesMonitoringUsageResponse {
|
message PushResourcesMonitoringUsageResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message Connection {
|
||||||
|
enum Action {
|
||||||
|
ACTION_UNSPECIFIED = 0;
|
||||||
|
CONNECT = 1;
|
||||||
|
DISCONNECT = 2;
|
||||||
|
}
|
||||||
|
enum Type {
|
||||||
|
TYPE_UNSPECIFIED = 0;
|
||||||
|
SSH = 1;
|
||||||
|
VSCODE = 2;
|
||||||
|
JETBRAINS = 3;
|
||||||
|
RECONNECTING_PTY = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes id = 1;
|
||||||
|
Action action = 2;
|
||||||
|
Type type = 3;
|
||||||
|
google.protobuf.Timestamp timestamp = 4;
|
||||||
|
string ip = 5;
|
||||||
|
int32 status_code = 6;
|
||||||
|
optional string reason = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ReportConnectionRequest {
|
||||||
|
Connection connection = 1;
|
||||||
|
}
|
||||||
|
|
||||||
service Agent {
|
service Agent {
|
||||||
rpc GetManifest(GetManifestRequest) returns (Manifest);
|
rpc GetManifest(GetManifestRequest) returns (Manifest);
|
||||||
rpc GetServiceBanner(GetServiceBannerRequest) returns (ServiceBanner);
|
rpc GetServiceBanner(GetServiceBannerRequest) returns (ServiceBanner);
|
||||||
@ -353,4 +381,5 @@ service Agent {
|
|||||||
rpc ScriptCompleted(WorkspaceAgentScriptCompletedRequest) returns (WorkspaceAgentScriptCompletedResponse);
|
rpc ScriptCompleted(WorkspaceAgentScriptCompletedRequest) returns (WorkspaceAgentScriptCompletedResponse);
|
||||||
rpc GetResourcesMonitoringConfiguration(GetResourcesMonitoringConfigurationRequest) returns (GetResourcesMonitoringConfigurationResponse);
|
rpc GetResourcesMonitoringConfiguration(GetResourcesMonitoringConfigurationRequest) returns (GetResourcesMonitoringConfigurationResponse);
|
||||||
rpc PushResourcesMonitoringUsage(PushResourcesMonitoringUsageRequest) returns (PushResourcesMonitoringUsageResponse);
|
rpc PushResourcesMonitoringUsage(PushResourcesMonitoringUsageRequest) returns (PushResourcesMonitoringUsageResponse);
|
||||||
|
rpc ReportConnection(ReportConnectionRequest) returns (google.protobuf.Empty);
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ import (
|
|||||||
errors "errors"
|
errors "errors"
|
||||||
protojson "google.golang.org/protobuf/encoding/protojson"
|
protojson "google.golang.org/protobuf/encoding/protojson"
|
||||||
proto "google.golang.org/protobuf/proto"
|
proto "google.golang.org/protobuf/proto"
|
||||||
|
emptypb "google.golang.org/protobuf/types/known/emptypb"
|
||||||
drpc "storj.io/drpc"
|
drpc "storj.io/drpc"
|
||||||
drpcerr "storj.io/drpc/drpcerr"
|
drpcerr "storj.io/drpc/drpcerr"
|
||||||
)
|
)
|
||||||
@ -50,6 +51,7 @@ type DRPCAgentClient interface {
|
|||||||
ScriptCompleted(ctx context.Context, in *WorkspaceAgentScriptCompletedRequest) (*WorkspaceAgentScriptCompletedResponse, error)
|
ScriptCompleted(ctx context.Context, in *WorkspaceAgentScriptCompletedRequest) (*WorkspaceAgentScriptCompletedResponse, error)
|
||||||
GetResourcesMonitoringConfiguration(ctx context.Context, in *GetResourcesMonitoringConfigurationRequest) (*GetResourcesMonitoringConfigurationResponse, error)
|
GetResourcesMonitoringConfiguration(ctx context.Context, in *GetResourcesMonitoringConfigurationRequest) (*GetResourcesMonitoringConfigurationResponse, error)
|
||||||
PushResourcesMonitoringUsage(ctx context.Context, in *PushResourcesMonitoringUsageRequest) (*PushResourcesMonitoringUsageResponse, error)
|
PushResourcesMonitoringUsage(ctx context.Context, in *PushResourcesMonitoringUsageRequest) (*PushResourcesMonitoringUsageResponse, error)
|
||||||
|
ReportConnection(ctx context.Context, in *ReportConnectionRequest) (*emptypb.Empty, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type drpcAgentClient struct {
|
type drpcAgentClient struct {
|
||||||
@ -170,6 +172,15 @@ func (c *drpcAgentClient) PushResourcesMonitoringUsage(ctx context.Context, in *
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *drpcAgentClient) ReportConnection(ctx context.Context, in *ReportConnectionRequest) (*emptypb.Empty, error) {
|
||||||
|
out := new(emptypb.Empty)
|
||||||
|
err := c.cc.Invoke(ctx, "/coder.agent.v2.Agent/ReportConnection", drpcEncoding_File_agent_proto_agent_proto{}, in, out)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
type DRPCAgentServer interface {
|
type DRPCAgentServer interface {
|
||||||
GetManifest(context.Context, *GetManifestRequest) (*Manifest, error)
|
GetManifest(context.Context, *GetManifestRequest) (*Manifest, error)
|
||||||
GetServiceBanner(context.Context, *GetServiceBannerRequest) (*ServiceBanner, error)
|
GetServiceBanner(context.Context, *GetServiceBannerRequest) (*ServiceBanner, error)
|
||||||
@ -183,6 +194,7 @@ type DRPCAgentServer interface {
|
|||||||
ScriptCompleted(context.Context, *WorkspaceAgentScriptCompletedRequest) (*WorkspaceAgentScriptCompletedResponse, error)
|
ScriptCompleted(context.Context, *WorkspaceAgentScriptCompletedRequest) (*WorkspaceAgentScriptCompletedResponse, error)
|
||||||
GetResourcesMonitoringConfiguration(context.Context, *GetResourcesMonitoringConfigurationRequest) (*GetResourcesMonitoringConfigurationResponse, error)
|
GetResourcesMonitoringConfiguration(context.Context, *GetResourcesMonitoringConfigurationRequest) (*GetResourcesMonitoringConfigurationResponse, error)
|
||||||
PushResourcesMonitoringUsage(context.Context, *PushResourcesMonitoringUsageRequest) (*PushResourcesMonitoringUsageResponse, error)
|
PushResourcesMonitoringUsage(context.Context, *PushResourcesMonitoringUsageRequest) (*PushResourcesMonitoringUsageResponse, error)
|
||||||
|
ReportConnection(context.Context, *ReportConnectionRequest) (*emptypb.Empty, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type DRPCAgentUnimplementedServer struct{}
|
type DRPCAgentUnimplementedServer struct{}
|
||||||
@ -235,9 +247,13 @@ func (s *DRPCAgentUnimplementedServer) PushResourcesMonitoringUsage(context.Cont
|
|||||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *DRPCAgentUnimplementedServer) ReportConnection(context.Context, *ReportConnectionRequest) (*emptypb.Empty, error) {
|
||||||
|
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||||
|
}
|
||||||
|
|
||||||
type DRPCAgentDescription struct{}
|
type DRPCAgentDescription struct{}
|
||||||
|
|
||||||
func (DRPCAgentDescription) NumMethods() int { return 12 }
|
func (DRPCAgentDescription) NumMethods() int { return 13 }
|
||||||
|
|
||||||
func (DRPCAgentDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver, interface{}, bool) {
|
func (DRPCAgentDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver, interface{}, bool) {
|
||||||
switch n {
|
switch n {
|
||||||
@ -349,6 +365,15 @@ func (DRPCAgentDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver,
|
|||||||
in1.(*PushResourcesMonitoringUsageRequest),
|
in1.(*PushResourcesMonitoringUsageRequest),
|
||||||
)
|
)
|
||||||
}, DRPCAgentServer.PushResourcesMonitoringUsage, true
|
}, DRPCAgentServer.PushResourcesMonitoringUsage, true
|
||||||
|
case 12:
|
||||||
|
return "/coder.agent.v2.Agent/ReportConnection", drpcEncoding_File_agent_proto_agent_proto{},
|
||||||
|
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||||
|
return srv.(DRPCAgentServer).
|
||||||
|
ReportConnection(
|
||||||
|
ctx,
|
||||||
|
in1.(*ReportConnectionRequest),
|
||||||
|
)
|
||||||
|
}, DRPCAgentServer.ReportConnection, true
|
||||||
default:
|
default:
|
||||||
return "", nil, nil, nil, false
|
return "", nil, nil, nil, false
|
||||||
}
|
}
|
||||||
@ -549,3 +574,19 @@ func (x *drpcAgent_PushResourcesMonitoringUsageStream) SendAndClose(m *PushResou
|
|||||||
}
|
}
|
||||||
return x.CloseSend()
|
return x.CloseSend()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DRPCAgent_ReportConnectionStream interface {
|
||||||
|
drpc.Stream
|
||||||
|
SendAndClose(*emptypb.Empty) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type drpcAgent_ReportConnectionStream struct {
|
||||||
|
drpc.Stream
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *drpcAgent_ReportConnectionStream) SendAndClose(m *emptypb.Empty) error {
|
||||||
|
if err := x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return x.CloseSend()
|
||||||
|
}
|
||||||
|
@ -3,6 +3,7 @@ package proto
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
emptypb "google.golang.org/protobuf/types/known/emptypb"
|
||||||
"storj.io/drpc"
|
"storj.io/drpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -41,10 +42,11 @@ type DRPCAgentClient23 interface {
|
|||||||
ScriptCompleted(ctx context.Context, in *WorkspaceAgentScriptCompletedRequest) (*WorkspaceAgentScriptCompletedResponse, error)
|
ScriptCompleted(ctx context.Context, in *WorkspaceAgentScriptCompletedRequest) (*WorkspaceAgentScriptCompletedResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DRPCAgentClient24 is the Agent API at v2.4. It adds the GetResourcesMonitoringConfiguration and
|
// DRPCAgentClient24 is the Agent API at v2.4. It adds the GetResourcesMonitoringConfiguration,
|
||||||
// PushResourcesMonitoringUsage RPCs. Compatible with Coder v2.19+
|
// PushResourcesMonitoringUsage and ReportConnection RPCs. Compatible with Coder v2.19+
|
||||||
type DRPCAgentClient24 interface {
|
type DRPCAgentClient24 interface {
|
||||||
DRPCAgentClient23
|
DRPCAgentClient23
|
||||||
GetResourcesMonitoringConfiguration(ctx context.Context, in *GetResourcesMonitoringConfigurationRequest) (*GetResourcesMonitoringConfigurationResponse, error)
|
GetResourcesMonitoringConfiguration(ctx context.Context, in *GetResourcesMonitoringConfigurationRequest) (*GetResourcesMonitoringConfigurationResponse, error)
|
||||||
PushResourcesMonitoringUsage(ctx context.Context, in *PushResourcesMonitoringUsageRequest) (*PushResourcesMonitoringUsageResponse, error)
|
PushResourcesMonitoringUsage(ctx context.Context, in *PushResourcesMonitoringUsageRequest) (*PushResourcesMonitoringUsageResponse, error)
|
||||||
|
ReportConnection(ctx context.Context, in *ReportConnectionRequest) (*emptypb.Empty, error)
|
||||||
}
|
}
|
||||||
|
@ -19,6 +19,7 @@ import (
|
|||||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||||
"github.com/coder/coder/v2/coderd/agentapi/resourcesmonitor"
|
"github.com/coder/coder/v2/coderd/agentapi/resourcesmonitor"
|
||||||
"github.com/coder/coder/v2/coderd/appearance"
|
"github.com/coder/coder/v2/coderd/appearance"
|
||||||
|
"github.com/coder/coder/v2/coderd/audit"
|
||||||
"github.com/coder/coder/v2/coderd/database"
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
"github.com/coder/coder/v2/coderd/database/pubsub"
|
"github.com/coder/coder/v2/coderd/database/pubsub"
|
||||||
"github.com/coder/coder/v2/coderd/externalauth"
|
"github.com/coder/coder/v2/coderd/externalauth"
|
||||||
@ -48,6 +49,7 @@ type API struct {
|
|||||||
*ResourcesMonitoringAPI
|
*ResourcesMonitoringAPI
|
||||||
*LogsAPI
|
*LogsAPI
|
||||||
*ScriptsAPI
|
*ScriptsAPI
|
||||||
|
*AuditAPI
|
||||||
*tailnet.DRPCService
|
*tailnet.DRPCService
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@ -66,6 +68,7 @@ type Options struct {
|
|||||||
Database database.Store
|
Database database.Store
|
||||||
NotificationsEnqueuer notifications.Enqueuer
|
NotificationsEnqueuer notifications.Enqueuer
|
||||||
Pubsub pubsub.Pubsub
|
Pubsub pubsub.Pubsub
|
||||||
|
Auditor *atomic.Pointer[audit.Auditor]
|
||||||
DerpMapFn func() *tailcfg.DERPMap
|
DerpMapFn func() *tailcfg.DERPMap
|
||||||
TailnetCoordinator *atomic.Pointer[tailnet.Coordinator]
|
TailnetCoordinator *atomic.Pointer[tailnet.Coordinator]
|
||||||
StatsReporter *workspacestats.Reporter
|
StatsReporter *workspacestats.Reporter
|
||||||
@ -174,6 +177,13 @@ func New(opts Options) *API {
|
|||||||
Database: opts.Database,
|
Database: opts.Database,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
api.AuditAPI = &AuditAPI{
|
||||||
|
AgentFn: api.agent,
|
||||||
|
Auditor: opts.Auditor,
|
||||||
|
Database: opts.Database,
|
||||||
|
Log: opts.Log,
|
||||||
|
}
|
||||||
|
|
||||||
api.DRPCService = &tailnet.DRPCService{
|
api.DRPCService = &tailnet.DRPCService{
|
||||||
CoordPtr: opts.TailnetCoordinator,
|
CoordPtr: opts.TailnetCoordinator,
|
||||||
Logger: opts.Log,
|
Logger: opts.Log,
|
||||||
|
105
coderd/agentapi/audit.go
Normal file
105
coderd/agentapi/audit.go
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
package agentapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/xerrors"
|
||||||
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
|
||||||
|
"cdr.dev/slog"
|
||||||
|
|
||||||
|
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||||
|
"github.com/coder/coder/v2/coderd/audit"
|
||||||
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
|
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||||
|
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditAPI struct {
|
||||||
|
AgentFn func(context.Context) (database.WorkspaceAgent, error)
|
||||||
|
Auditor *atomic.Pointer[audit.Auditor]
|
||||||
|
Database database.Store
|
||||||
|
Log slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuditAPI) ReportConnection(ctx context.Context, req *agentproto.ReportConnectionRequest) (*emptypb.Empty, error) {
|
||||||
|
// We will use connection ID as request ID, typically this is the
|
||||||
|
// SSH session ID as reported by the agent.
|
||||||
|
connectionID, err := uuid.FromBytes(req.GetConnection().GetId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerrors.Errorf("connection id from bytes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
action, err := db2sdk.AuditActionFromAgentProtoConnectionAction(req.GetConnection().GetAction())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
connectionType, err := agentsdk.ConnectionTypeFromProto(req.GetConnection().GetType())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch contextual data for this audit event.
|
||||||
|
workspaceAgent, err := a.AgentFn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerrors.Errorf("get agent: %w", err)
|
||||||
|
}
|
||||||
|
workspace, err := a.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerrors.Errorf("get workspace by agent id: %w", err)
|
||||||
|
}
|
||||||
|
build, err := a.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerrors.Errorf("get latest workspace build by workspace id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// We pass the below information to the Auditor so that it
|
||||||
|
// can form a friendly string for the user to view in the UI.
|
||||||
|
type additionalFields struct {
|
||||||
|
audit.AdditionalFields
|
||||||
|
|
||||||
|
ConnectionType agentsdk.ConnectionType `json:"connection_type"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
resourceInfo := additionalFields{
|
||||||
|
AdditionalFields: audit.AdditionalFields{
|
||||||
|
WorkspaceID: workspace.ID,
|
||||||
|
WorkspaceName: workspace.Name,
|
||||||
|
WorkspaceOwner: workspace.OwnerUsername,
|
||||||
|
BuildNumber: strconv.FormatInt(int64(build.BuildNumber), 10),
|
||||||
|
BuildReason: database.BuildReason(string(build.Reason)),
|
||||||
|
},
|
||||||
|
ConnectionType: connectionType,
|
||||||
|
Reason: req.GetConnection().GetReason(),
|
||||||
|
}
|
||||||
|
|
||||||
|
riBytes, err := json.Marshal(resourceInfo)
|
||||||
|
if err != nil {
|
||||||
|
a.Log.Error(ctx, "marshal resource info for agent connection failed", slog.Error(err))
|
||||||
|
riBytes = []byte("{}")
|
||||||
|
}
|
||||||
|
|
||||||
|
audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.WorkspaceAgent]{
|
||||||
|
Audit: *a.Auditor.Load(),
|
||||||
|
Log: a.Log,
|
||||||
|
Time: req.GetConnection().GetTimestamp().AsTime(),
|
||||||
|
OrganizationID: workspace.OrganizationID,
|
||||||
|
RequestID: connectionID,
|
||||||
|
Action: action,
|
||||||
|
New: workspaceAgent,
|
||||||
|
Old: workspaceAgent,
|
||||||
|
IP: req.GetConnection().GetIp(),
|
||||||
|
Status: int(req.GetConnection().GetStatusCode()),
|
||||||
|
AdditionalFields: riBytes,
|
||||||
|
|
||||||
|
// It's not possible to tell which user connected. Once we have
|
||||||
|
// the capability, this may be reported by the agent.
|
||||||
|
UserID: uuid.Nil,
|
||||||
|
})
|
||||||
|
|
||||||
|
return &emptypb.Empty{}, nil
|
||||||
|
}
|
179
coderd/agentapi/audit_test.go
Normal file
179
coderd/agentapi/audit_test.go
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
package agentapi_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/sqlc-dev/pqtype"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/mock/gomock"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
|
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||||
|
"github.com/coder/coder/v2/coderd/agentapi"
|
||||||
|
"github.com/coder/coder/v2/coderd/audit"
|
||||||
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
|
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||||
|
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||||
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||||
|
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuditReport(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
owner = database.User{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Username: "cool-user",
|
||||||
|
}
|
||||||
|
workspace = database.Workspace{
|
||||||
|
ID: uuid.New(),
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
OwnerID: owner.ID,
|
||||||
|
Name: "cool-workspace",
|
||||||
|
}
|
||||||
|
build = database.WorkspaceBuild{
|
||||||
|
ID: uuid.New(),
|
||||||
|
WorkspaceID: workspace.ID,
|
||||||
|
}
|
||||||
|
agent = database.WorkspaceAgent{
|
||||||
|
ID: uuid.New(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
id uuid.UUID
|
||||||
|
action *agentproto.Connection_Action
|
||||||
|
typ *agentproto.Connection_Type
|
||||||
|
time time.Time
|
||||||
|
ip string
|
||||||
|
status int32
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "SSH Connect",
|
||||||
|
id: uuid.New(),
|
||||||
|
action: agentproto.Connection_CONNECT.Enum(),
|
||||||
|
typ: agentproto.Connection_SSH.Enum(),
|
||||||
|
time: time.Now(),
|
||||||
|
ip: "127.0.0.1",
|
||||||
|
status: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VS Code Connect",
|
||||||
|
id: uuid.New(),
|
||||||
|
action: agentproto.Connection_CONNECT.Enum(),
|
||||||
|
typ: agentproto.Connection_VSCODE.Enum(),
|
||||||
|
time: time.Now(),
|
||||||
|
ip: "8.8.8.8",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "JetBrains Connect",
|
||||||
|
id: uuid.New(),
|
||||||
|
action: agentproto.Connection_CONNECT.Enum(),
|
||||||
|
typ: agentproto.Connection_JETBRAINS.Enum(),
|
||||||
|
time: time.Now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Reconnecting PTY Connect",
|
||||||
|
id: uuid.New(),
|
||||||
|
action: agentproto.Connection_CONNECT.Enum(),
|
||||||
|
typ: agentproto.Connection_RECONNECTING_PTY.Enum(),
|
||||||
|
time: time.Now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SSH Disconnect",
|
||||||
|
id: uuid.New(),
|
||||||
|
action: agentproto.Connection_DISCONNECT.Enum(),
|
||||||
|
typ: agentproto.Connection_SSH.Enum(),
|
||||||
|
time: time.Now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SSH Disconnect",
|
||||||
|
id: uuid.New(),
|
||||||
|
action: agentproto.Connection_DISCONNECT.Enum(),
|
||||||
|
typ: agentproto.Connection_SSH.Enum(),
|
||||||
|
time: time.Now(),
|
||||||
|
status: 500,
|
||||||
|
reason: "because error says so",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
//nolint:paralleltest // No longer necessary to reinitialise the variable tt.
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mAudit := audit.NewMock()
|
||||||
|
|
||||||
|
mDB := dbmock.NewMockStore(gomock.NewController(t))
|
||||||
|
mDB.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(workspace, nil)
|
||||||
|
mDB.EXPECT().GetLatestWorkspaceBuildByWorkspaceID(gomock.Any(), workspace.ID).Return(build, nil)
|
||||||
|
|
||||||
|
api := &agentapi.AuditAPI{
|
||||||
|
Auditor: asAtomicPointer[audit.Auditor](mAudit),
|
||||||
|
Database: mDB,
|
||||||
|
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
||||||
|
return agent, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
api.ReportConnection(context.Background(), &agentproto.ReportConnectionRequest{
|
||||||
|
Connection: &agentproto.Connection{
|
||||||
|
Id: tt.id[:],
|
||||||
|
Action: *tt.action,
|
||||||
|
Type: *tt.typ,
|
||||||
|
Timestamp: timestamppb.New(tt.time),
|
||||||
|
Ip: tt.ip,
|
||||||
|
StatusCode: tt.status,
|
||||||
|
Reason: &tt.reason,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
mAudit.Contains(t, database.AuditLog{
|
||||||
|
Time: dbtime.Time(tt.time).In(time.UTC),
|
||||||
|
Action: agentProtoConnectionActionToAudit(t, *tt.action),
|
||||||
|
OrganizationID: workspace.OrganizationID,
|
||||||
|
UserID: uuid.Nil,
|
||||||
|
RequestID: tt.id,
|
||||||
|
ResourceType: database.ResourceTypeWorkspaceAgent,
|
||||||
|
ResourceID: agent.ID,
|
||||||
|
ResourceTarget: agent.Name,
|
||||||
|
Ip: pqtype.Inet{Valid: true, IPNet: net.IPNet{IP: net.ParseIP(tt.ip), Mask: net.CIDRMask(32, 32)}},
|
||||||
|
StatusCode: tt.status,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check some additional fields.
|
||||||
|
var m map[string]any
|
||||||
|
err := json.Unmarshal(mAudit.AuditLogs()[0].AdditionalFields, &m)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, string(agentProtoConnectionTypeToSDK(t, *tt.typ)), m["connection_type"].(string))
|
||||||
|
if tt.reason != "" {
|
||||||
|
require.Equal(t, tt.reason, m["reason"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func agentProtoConnectionActionToAudit(t *testing.T, action agentproto.Connection_Action) database.AuditAction {
|
||||||
|
a, err := db2sdk.AuditActionFromAgentProtoConnectionAction(action)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func agentProtoConnectionTypeToSDK(t *testing.T, typ agentproto.Connection_Type) agentsdk.ConnectionType {
|
||||||
|
action, err := agentsdk.ConnectionTypeFromProto(typ)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
|
||||||
|
func asAtomicPointer[T any](v T) *atomic.Pointer[T] {
|
||||||
|
var p atomic.Pointer[T]
|
||||||
|
p.Store(&v)
|
||||||
|
return &p
|
||||||
|
}
|
@ -9,6 +9,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/sqlc-dev/pqtype"
|
"github.com/sqlc-dev/pqtype"
|
||||||
@ -65,6 +66,7 @@ type BackgroundAuditParams[T Auditable] struct {
|
|||||||
|
|
||||||
UserID uuid.UUID
|
UserID uuid.UUID
|
||||||
RequestID uuid.UUID
|
RequestID uuid.UUID
|
||||||
|
Time time.Time
|
||||||
Status int
|
Status int
|
||||||
Action database.AuditAction
|
Action database.AuditAction
|
||||||
OrganizationID uuid.UUID
|
OrganizationID uuid.UUID
|
||||||
@ -461,13 +463,19 @@ func BackgroundAudit[T Auditable](ctx context.Context, p *BackgroundAuditParams[
|
|||||||
diffRaw = []byte("{}")
|
diffRaw = []byte("{}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if p.Time.IsZero() {
|
||||||
|
p.Time = dbtime.Now()
|
||||||
|
} else {
|
||||||
|
// NOTE(mafredri): dbtime.Time does not currently enforce UTC.
|
||||||
|
p.Time = dbtime.Time(p.Time.In(time.UTC))
|
||||||
|
}
|
||||||
if p.AdditionalFields == nil {
|
if p.AdditionalFields == nil {
|
||||||
p.AdditionalFields = json.RawMessage("{}")
|
p.AdditionalFields = json.RawMessage("{}")
|
||||||
}
|
}
|
||||||
|
|
||||||
auditLog := database.AuditLog{
|
auditLog := database.AuditLog{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
Time: dbtime.Now(),
|
Time: p.Time,
|
||||||
UserID: p.UserID,
|
UserID: p.UserID,
|
||||||
OrganizationID: requireOrgID[T](ctx, p.OrganizationID, p.Log),
|
OrganizationID: requireOrgID[T](ctx, p.OrganizationID, p.Log),
|
||||||
Ip: ip,
|
Ip: ip,
|
||||||
|
@ -15,6 +15,7 @@ import (
|
|||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
"tailscale.com/tailcfg"
|
"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"
|
||||||
"github.com/coder/coder/v2/coderd/rbac"
|
"github.com/coder/coder/v2/coderd/rbac"
|
||||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||||
@ -705,3 +706,26 @@ func TemplateRoleActions(role codersdk.TemplateRole) []policy.Action {
|
|||||||
}
|
}
|
||||||
return []policy.Action{}
|
return []policy.Action{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func AuditActionFromAgentProtoConnectionAction(action agentproto.Connection_Action) (database.AuditAction, error) {
|
||||||
|
switch action {
|
||||||
|
case agentproto.Connection_CONNECT:
|
||||||
|
return database.AuditActionConnect, nil
|
||||||
|
case agentproto.Connection_DISCONNECT:
|
||||||
|
return database.AuditActionDisconnect, nil
|
||||||
|
default:
|
||||||
|
// Also Connection_ACTION_UNSPECIFIED, no mapping.
|
||||||
|
return "", xerrors.Errorf("unknown agent connection action %q", action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AgentProtoConnectionActionToAuditAction(action database.AuditAction) (agentproto.Connection_Action, error) {
|
||||||
|
switch action {
|
||||||
|
case database.AuditActionConnect:
|
||||||
|
return agentproto.Connection_CONNECT, nil
|
||||||
|
case database.AuditActionDisconnect:
|
||||||
|
return agentproto.Connection_DISCONNECT, nil
|
||||||
|
default:
|
||||||
|
return agentproto.Connection_ACTION_UNSPECIFIED, xerrors.Errorf("unknown agent connection action %q", action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -147,6 +147,7 @@ func (api *API) workspaceAgentRPC(rw http.ResponseWriter, r *http.Request) {
|
|||||||
Database: api.Database,
|
Database: api.Database,
|
||||||
NotificationsEnqueuer: api.NotificationsEnqueuer,
|
NotificationsEnqueuer: api.NotificationsEnqueuer,
|
||||||
Pubsub: api.Pubsub,
|
Pubsub: api.Pubsub,
|
||||||
|
Auditor: &api.Auditor,
|
||||||
DerpMapFn: api.DERPMap,
|
DerpMapFn: api.DERPMap,
|
||||||
TailnetCoordinator: &api.TailnetCoordinator,
|
TailnetCoordinator: &api.TailnetCoordinator,
|
||||||
AppearanceFetcher: &api.AppearanceFetcher,
|
AppearanceFetcher: &api.AppearanceFetcher,
|
||||||
|
@ -34,6 +34,18 @@ import (
|
|||||||
// log-source. This should be removed in the future.
|
// log-source. This should be removed in the future.
|
||||||
var ExternalLogSourceID = uuid.MustParse("3b579bf4-1ed8-4b99-87a8-e9a1e3410410")
|
var ExternalLogSourceID = uuid.MustParse("3b579bf4-1ed8-4b99-87a8-e9a1e3410410")
|
||||||
|
|
||||||
|
// ConnectionType is the type of connection that the agent is receiving.
|
||||||
|
type ConnectionType string
|
||||||
|
|
||||||
|
// Connection type enums.
|
||||||
|
const (
|
||||||
|
ConnectionTypeUnspecified ConnectionType = "Unspecified"
|
||||||
|
ConnectionTypeSSH ConnectionType = "SSH"
|
||||||
|
ConnectionTypeVSCode ConnectionType = "VS Code"
|
||||||
|
ConnectionTypeJetBrains ConnectionType = "JetBrains"
|
||||||
|
ConnectionTypeReconnectingPTY ConnectionType = "Web Terminal"
|
||||||
|
)
|
||||||
|
|
||||||
// New returns a client that is used to interact with the
|
// New returns a client that is used to interact with the
|
||||||
// Coder API from a workspace agent.
|
// Coder API from a workspace agent.
|
||||||
func New(serverURL *url.URL) *Client {
|
func New(serverURL *url.URL) *Client {
|
||||||
|
@ -390,3 +390,37 @@ func ProtoFromLifecycleState(s codersdk.WorkspaceAgentLifecycle) (proto.Lifecycl
|
|||||||
}
|
}
|
||||||
return proto.Lifecycle_State(caps), nil
|
return proto.Lifecycle_State(caps), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ConnectionTypeFromProto(typ proto.Connection_Type) (ConnectionType, error) {
|
||||||
|
switch typ {
|
||||||
|
case proto.Connection_TYPE_UNSPECIFIED:
|
||||||
|
return ConnectionTypeUnspecified, nil
|
||||||
|
case proto.Connection_SSH:
|
||||||
|
return ConnectionTypeSSH, nil
|
||||||
|
case proto.Connection_VSCODE:
|
||||||
|
return ConnectionTypeVSCode, nil
|
||||||
|
case proto.Connection_JETBRAINS:
|
||||||
|
return ConnectionTypeJetBrains, nil
|
||||||
|
case proto.Connection_RECONNECTING_PTY:
|
||||||
|
return ConnectionTypeReconnectingPTY, nil
|
||||||
|
default:
|
||||||
|
return "", xerrors.Errorf("unknown connection type %q", typ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProtoFromConnectionType(typ ConnectionType) (proto.Connection_Type, error) {
|
||||||
|
switch typ {
|
||||||
|
case ConnectionTypeUnspecified:
|
||||||
|
return proto.Connection_TYPE_UNSPECIFIED, nil
|
||||||
|
case ConnectionTypeSSH:
|
||||||
|
return proto.Connection_SSH, nil
|
||||||
|
case ConnectionTypeVSCode:
|
||||||
|
return proto.Connection_VSCODE, nil
|
||||||
|
case ConnectionTypeJetBrains:
|
||||||
|
return proto.Connection_JETBRAINS, nil
|
||||||
|
case ConnectionTypeReconnectingPTY:
|
||||||
|
return proto.Connection_RECONNECTING_PTY, nil
|
||||||
|
default:
|
||||||
|
return 0, xerrors.Errorf("unknown connection type %q", typ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -36,7 +36,7 @@ type DRPCTailnetClient23 interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DRPCTailnetClient24 is the Tailnet API at v2.4. It is functionally identical to 2.3, because the
|
// DRPCTailnetClient24 is the Tailnet API at v2.4. It is functionally identical to 2.3, because the
|
||||||
// change was to the Agent API (ResourcesMonitoring methods).
|
// change was to the Agent API (ResourcesMonitoring and ReportConnection methods).
|
||||||
type DRPCTailnetClient24 interface {
|
type DRPCTailnetClient24 interface {
|
||||||
DRPCTailnetClient23
|
DRPCTailnetClient23
|
||||||
}
|
}
|
||||||
|
@ -43,6 +43,8 @@ import (
|
|||||||
// - Shipped in Coder v2.{{placeholder}} // TODO Vincent: Replace with the correct version
|
// - Shipped in Coder v2.{{placeholder}} // TODO Vincent: Replace with the correct version
|
||||||
// - Added support for GetResourcesMonitoringConfiguration and
|
// - Added support for GetResourcesMonitoringConfiguration and
|
||||||
// PushResourcesMonitoringUsage RPCs on the Agent API.
|
// PushResourcesMonitoringUsage RPCs on the Agent API.
|
||||||
|
// - Added support for reporting connection events for auditing via the
|
||||||
|
// ReportConnection RPC on the Agent API.
|
||||||
const (
|
const (
|
||||||
CurrentMajor = 2
|
CurrentMajor = 2
|
||||||
CurrentMinor = 4
|
CurrentMinor = 4
|
||||||
|
Reference in New Issue
Block a user