diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index bcc7443c1c928..ab5a27dd3b163 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -383,6 +383,52 @@ const docTemplate = `{ } } }, + "/connectionlog": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get connection logs", + "operationId": "get-connection-logs", + "parameters": [ + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query" + }, + { + "type": "integer", + "description": "Page limit", + "name": "limit", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ConnectionLogResponse" + } + } + } + } + }, "/csp/reports": { "post": { "security": [ @@ -11444,6 +11490,139 @@ const docTemplate = `{ } } }, + "codersdk.ConnectionLog": { + "type": "object", + "properties": { + "agent_name": { + "type": "string" + }, + "connect_time": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "ip": { + "type": "string" + }, + "organization": { + "$ref": "#/definitions/codersdk.MinimalOrganization" + }, + "ssh_info": { + "description": "SSHInfo is only set when ` + "`" + `type` + "`" + ` is one of:\n- ` + "`" + `ConnectionTypeSSH` + "`" + `\n- ` + "`" + `ConnectionTypeReconnectingPTY` + "`" + `\n- ` + "`" + `ConnectionTypeVSCode` + "`" + `\n- ` + "`" + `ConnectionTypeJetBrains` + "`" + `", + "allOf": [ + { + "$ref": "#/definitions/codersdk.ConnectionLogSSHInfo" + } + ] + }, + "type": { + "$ref": "#/definitions/codersdk.ConnectionType" + }, + "web_info": { + "description": "WebInfo is only set when ` + "`" + `type` + "`" + ` is one of:\n- ` + "`" + `ConnectionTypePortForwarding` + "`" + `\n- ` + "`" + `ConnectionTypeWorkspaceApp` + "`" + `", + "allOf": [ + { + "$ref": "#/definitions/codersdk.ConnectionLogWebInfo" + } + ] + }, + "workspace_id": { + "type": "string", + "format": "uuid" + }, + "workspace_name": { + "type": "string" + }, + "workspace_owner_id": { + "type": "string", + "format": "uuid" + }, + "workspace_owner_username": { + "type": "string" + } + } + }, + "codersdk.ConnectionLogResponse": { + "type": "object", + "properties": { + "connection_logs": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ConnectionLog" + } + }, + "count": { + "type": "integer" + } + } + }, + "codersdk.ConnectionLogSSHInfo": { + "type": "object", + "properties": { + "connection_id": { + "type": "string", + "format": "uuid" + }, + "disconnect_reason": { + "description": "DisconnectReason is omitted if a disconnect event with the same connection ID\nhas not yet been seen.", + "type": "string" + }, + "disconnect_time": { + "description": "DisconnectTime is omitted if a disconnect event with the same connection ID\nhas not yet been seen.", + "type": "string", + "format": "date-time" + }, + "exit_code": { + "description": "ExitCode is the exit code of the SSH session. It is omitted if a\ndisconnect event with the same connection ID has not yet been seen.", + "type": "integer" + } + } + }, + "codersdk.ConnectionLogWebInfo": { + "type": "object", + "properties": { + "slug_or_port": { + "type": "string" + }, + "status_code": { + "description": "StatusCode is the HTTP status code of the request.", + "type": "integer" + }, + "user": { + "description": "User is omitted if the connection event was from an unauthenticated user.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.User" + } + ] + }, + "user_agent": { + "type": "string" + } + } + }, + "codersdk.ConnectionType": { + "type": "string", + "enum": [ + "ssh", + "vscode", + "jetbrains", + "reconnecting_pty", + "workspace_app", + "port_forwarding" + ], + "x-enum-varnames": [ + "ConnectionTypeSSH", + "ConnectionTypeVSCode", + "ConnectionTypeJetBrains", + "ConnectionTypeReconnectingPTY", + "ConnectionTypeWorkspaceApp", + "ConnectionTypePortForwarding" + ] + }, "codersdk.ConvertLoginRequest": { "type": "object", "required": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 8485df8f2a745..f14b86b549065 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -323,6 +323,48 @@ } } }, + "/connectionlog": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get connection logs", + "operationId": "get-connection-logs", + "parameters": [ + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query" + }, + { + "type": "integer", + "description": "Page limit", + "name": "limit", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ConnectionLogResponse" + } + } + } + } + }, "/csp/reports": { "post": { "security": [ @@ -10174,6 +10216,139 @@ } } }, + "codersdk.ConnectionLog": { + "type": "object", + "properties": { + "agent_name": { + "type": "string" + }, + "connect_time": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "ip": { + "type": "string" + }, + "organization": { + "$ref": "#/definitions/codersdk.MinimalOrganization" + }, + "ssh_info": { + "description": "SSHInfo is only set when `type` is one of:\n- `ConnectionTypeSSH`\n- `ConnectionTypeReconnectingPTY`\n- `ConnectionTypeVSCode`\n- `ConnectionTypeJetBrains`", + "allOf": [ + { + "$ref": "#/definitions/codersdk.ConnectionLogSSHInfo" + } + ] + }, + "type": { + "$ref": "#/definitions/codersdk.ConnectionType" + }, + "web_info": { + "description": "WebInfo is only set when `type` is one of:\n- `ConnectionTypePortForwarding`\n- `ConnectionTypeWorkspaceApp`", + "allOf": [ + { + "$ref": "#/definitions/codersdk.ConnectionLogWebInfo" + } + ] + }, + "workspace_id": { + "type": "string", + "format": "uuid" + }, + "workspace_name": { + "type": "string" + }, + "workspace_owner_id": { + "type": "string", + "format": "uuid" + }, + "workspace_owner_username": { + "type": "string" + } + } + }, + "codersdk.ConnectionLogResponse": { + "type": "object", + "properties": { + "connection_logs": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ConnectionLog" + } + }, + "count": { + "type": "integer" + } + } + }, + "codersdk.ConnectionLogSSHInfo": { + "type": "object", + "properties": { + "connection_id": { + "type": "string", + "format": "uuid" + }, + "disconnect_reason": { + "description": "DisconnectReason is omitted if a disconnect event with the same connection ID\nhas not yet been seen.", + "type": "string" + }, + "disconnect_time": { + "description": "DisconnectTime is omitted if a disconnect event with the same connection ID\nhas not yet been seen.", + "type": "string", + "format": "date-time" + }, + "exit_code": { + "description": "ExitCode is the exit code of the SSH session. It is omitted if a\ndisconnect event with the same connection ID has not yet been seen.", + "type": "integer" + } + } + }, + "codersdk.ConnectionLogWebInfo": { + "type": "object", + "properties": { + "slug_or_port": { + "type": "string" + }, + "status_code": { + "description": "StatusCode is the HTTP status code of the request.", + "type": "integer" + }, + "user": { + "description": "User is omitted if the connection event was from an unauthenticated user.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.User" + } + ] + }, + "user_agent": { + "type": "string" + } + } + }, + "codersdk.ConnectionType": { + "type": "string", + "enum": [ + "ssh", + "vscode", + "jetbrains", + "reconnecting_pty", + "workspace_app", + "port_forwarding" + ], + "x-enum-varnames": [ + "ConnectionTypeSSH", + "ConnectionTypeVSCode", + "ConnectionTypeJetBrains", + "ConnectionTypeReconnectingPTY", + "ConnectionTypeWorkspaceApp", + "ConnectionTypePortForwarding" + ] + }, "codersdk.ConvertLoginRequest": { "type": "object", "required": ["password", "to_type"], diff --git a/coderd/audit.go b/coderd/audit.go index 786707768c05e..e8d7c4dfe9bca 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -40,7 +40,7 @@ func (api *API) auditLogs(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) - page, ok := parsePagination(rw, r) + page, ok := ParsePagination(rw, r) if !ok { return } diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index c0892aebdeb01..193ac3daa46bf 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -630,6 +630,19 @@ func (q *sqlQuerier) GetAuthorizedConnectionLogsOffset(ctx context.Context, arg query := fmt.Sprintf("-- name: GetAuthorizedConnectionLogsOffset :many\n%s", filtered) rows, err := q.db.QueryContext(ctx, query, + arg.OrganizationID, + arg.WorkspaceOwner, + arg.WorkspaceOwnerID, + arg.WorkspaceOwnerEmail, + arg.Type, + arg.UserID, + arg.Username, + arg.UserEmail, + arg.ConnectedAfter, + arg.ConnectedBefore, + arg.WorkspaceID, + arg.ConnectionID, + arg.Status, arg.OffsetOpt, arg.LimitOpt, ) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 298813276f902..a3d48e46b4fe7 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -32,6 +32,7 @@ import ( "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionersdk" "github.com/coder/coder/v2/testutil" ) @@ -2245,6 +2246,249 @@ func TestGetAuthorizedConnectionLogsOffset(t *testing.T) { }) } +func TestConnectionLogsOffsetFilters(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + db, _ := dbtestutil.NewDB(t) + + orgA := dbfake.Organization(t, db).Do() + orgB := dbfake.Organization(t, db).Do() + + user1 := dbgen.User(t, db, database.User{ + Username: "user1", + Email: "user1@test.com", + }) + user2 := dbgen.User(t, db, database.User{ + Username: "user2", + Email: "user2@test.com", + }) + user3 := dbgen.User(t, db, database.User{ + Username: "user3", + Email: "user3@test.com", + }) + + ws1Tpl := dbgen.Template(t, db, database.Template{OrganizationID: orgA.Org.ID, CreatedBy: user1.ID}) + ws1 := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user1.ID, + OrganizationID: orgA.Org.ID, + TemplateID: ws1Tpl.ID, + }) + ws2Tpl := dbgen.Template(t, db, database.Template{OrganizationID: orgB.Org.ID, CreatedBy: user2.ID}) + ws2 := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user2.ID, + OrganizationID: orgB.Org.ID, + TemplateID: ws2Tpl.ID, + }) + + now := dbtime.Now() + log1ConnID := uuid.New() + log1 := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Time: now.Add(-4 * time.Hour), + OrganizationID: ws1.OrganizationID, + WorkspaceOwnerID: ws1.OwnerID, + WorkspaceID: ws1.ID, + WorkspaceName: ws1.Name, + Type: database.ConnectionTypeWorkspaceApp, + ConnectionStatus: database.ConnectionStatusConnected, + UserID: uuid.NullUUID{UUID: user1.ID, Valid: true}, + UserAgent: sql.NullString{String: "Mozilla/5.0", Valid: true}, + SlugOrPort: sql.NullString{String: "code-server", Valid: true}, + ConnectionID: uuid.NullUUID{UUID: log1ConnID, Valid: true}, + }) + + log2ConnID := uuid.New() + log2 := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Time: now.Add(-3 * time.Hour), + OrganizationID: ws1.OrganizationID, + WorkspaceOwnerID: ws1.OwnerID, + WorkspaceID: ws1.ID, + WorkspaceName: ws1.Name, + Type: database.ConnectionTypeVscode, + ConnectionStatus: database.ConnectionStatusConnected, + ConnectionID: uuid.NullUUID{UUID: log2ConnID, Valid: true}, + }) + + // Mark log2 as disconnected + log2 = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Time: now.Add(-2 * time.Hour), + ConnectionID: log2.ConnectionID, + WorkspaceID: ws1.ID, + WorkspaceOwnerID: ws1.OwnerID, + AgentName: log2.AgentName, + ConnectionStatus: database.ConnectionStatusDisconnected, + + OrganizationID: log2.OrganizationID, + }) + + log3ConnID := uuid.New() + log3 := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Time: now.Add(-2 * time.Hour), + OrganizationID: ws2.OrganizationID, + WorkspaceOwnerID: ws2.OwnerID, + WorkspaceID: ws2.ID, + WorkspaceName: ws2.Name, + Type: database.ConnectionTypeSsh, + ConnectionStatus: database.ConnectionStatusConnected, + UserID: uuid.NullUUID{UUID: user2.ID, Valid: true}, + ConnectionID: uuid.NullUUID{UUID: log3ConnID, Valid: true}, + }) + + // Mark log3 as disconnected + log3 = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Time: now.Add(-1 * time.Hour), + ConnectionID: log3.ConnectionID, + WorkspaceOwnerID: log3.WorkspaceOwnerID, + WorkspaceID: ws2.ID, + AgentName: log3.AgentName, + ConnectionStatus: database.ConnectionStatusDisconnected, + + OrganizationID: log3.OrganizationID, + }) + + log4 := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Time: now.Add(-1 * time.Hour), + OrganizationID: ws2.OrganizationID, + WorkspaceOwnerID: ws2.OwnerID, + WorkspaceID: ws2.ID, + WorkspaceName: ws2.Name, + Type: database.ConnectionTypeVscode, + ConnectionStatus: database.ConnectionStatusConnected, + UserID: uuid.NullUUID{UUID: user3.ID, Valid: true}, + }) + + testCases := []struct { + name string + params database.GetConnectionLogsOffsetParams + expectedLogIDs []uuid.UUID + }{ + { + name: "NoFilter", + params: database.GetConnectionLogsOffsetParams{}, + expectedLogIDs: []uuid.UUID{ + log1.ID, log2.ID, log3.ID, log4.ID, + }, + }, + { + name: "OrganizationID", + params: database.GetConnectionLogsOffsetParams{ + OrganizationID: orgB.Org.ID, + }, + expectedLogIDs: []uuid.UUID{log3.ID, log4.ID}, + }, + { + name: "WorkspaceOwner", + params: database.GetConnectionLogsOffsetParams{ + WorkspaceOwner: user1.Username, + }, + expectedLogIDs: []uuid.UUID{log1.ID, log2.ID}, + }, + { + name: "WorkspaceOwnerID", + params: database.GetConnectionLogsOffsetParams{ + WorkspaceOwnerID: user1.ID, + }, + expectedLogIDs: []uuid.UUID{log1.ID, log2.ID}, + }, + { + name: "WorkspaceOwnerEmail", + params: database.GetConnectionLogsOffsetParams{ + WorkspaceOwnerEmail: user2.Email, + }, + expectedLogIDs: []uuid.UUID{log3.ID, log4.ID}, + }, + { + name: "Type", + params: database.GetConnectionLogsOffsetParams{ + Type: string(database.ConnectionTypeVscode), + }, + expectedLogIDs: []uuid.UUID{log2.ID, log4.ID}, + }, + { + name: "UserID", + params: database.GetConnectionLogsOffsetParams{ + UserID: user1.ID, + }, + expectedLogIDs: []uuid.UUID{log1.ID}, + }, + { + name: "Username", + params: database.GetConnectionLogsOffsetParams{ + Username: user1.Username, + }, + expectedLogIDs: []uuid.UUID{log1.ID}, + }, + { + name: "UserEmail", + params: database.GetConnectionLogsOffsetParams{ + UserEmail: user3.Email, + }, + expectedLogIDs: []uuid.UUID{log4.ID}, + }, + { + name: "ConnectedAfter", + params: database.GetConnectionLogsOffsetParams{ + ConnectedAfter: now.Add(-90 * time.Minute), // 1.5 hours ago + }, + expectedLogIDs: []uuid.UUID{log4.ID}, + }, + { + name: "ConnectedBefore", + params: database.GetConnectionLogsOffsetParams{ + ConnectedBefore: now.Add(-150 * time.Minute), + }, + expectedLogIDs: []uuid.UUID{log1.ID, log2.ID}, + }, + { + name: "WorkspaceID", + params: database.GetConnectionLogsOffsetParams{ + WorkspaceID: ws2.ID, + }, + expectedLogIDs: []uuid.UUID{log3.ID, log4.ID}, + }, + { + name: "ConnectionID", + params: database.GetConnectionLogsOffsetParams{ + ConnectionID: log1.ConnectionID.UUID, + }, + expectedLogIDs: []uuid.UUID{log1.ID}, + }, + { + name: "StatusOngoing", + params: database.GetConnectionLogsOffsetParams{ + Status: string(codersdk.ConnectionLogStatusOngoing), + }, + expectedLogIDs: []uuid.UUID{log4.ID}, + }, + { + name: "StatusCompleted", + params: database.GetConnectionLogsOffsetParams{ + Status: string(codersdk.ConnectionLogStatusCompleted), + }, + expectedLogIDs: []uuid.UUID{log2.ID, log3.ID}, + }, + { + name: "OrganizationAndTypeAndStatus", + params: database.GetConnectionLogsOffsetParams{ + OrganizationID: orgA.Org.ID, + Type: string(database.ConnectionTypeVscode), + Status: string(codersdk.ConnectionLogStatusCompleted), + }, + expectedLogIDs: []uuid.UUID{log2.ID}, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + logs, err := db.GetConnectionLogsOffset(ctx, tc.params) + require.NoError(t, err) + require.ElementsMatch(t, tc.expectedLogIDs, connectionOnlyIDs(logs)) + }) + } +} + func connectionOnlyIDs[T database.ConnectionLog | database.GetConnectionLogsOffsetRow](logs []T) []uuid.UUID { ids := make([]uuid.UUID, 0, len(logs)) for _, log := range logs { @@ -2313,7 +2557,7 @@ func TestUpsertConnectionLog(t *testing.T) { log1, err := db.UpsertConnectionLog(ctx, connectParams) require.NoError(t, err) require.Equal(t, connectParams.ID, log1.ID) - require.False(t, log1.DisconnectTime.Valid, "CloseTime should not be set on connect") + require.False(t, log1.DisconnectTime.Valid, "DisconnectTime should not be set on connect") // Check that one row exists. rows, err := db.GetConnectionLogsOffset(ctx, database.GetConnectionLogsOffsetParams{LimitOpt: 10}) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 23f7cf3bfbca0..cef983eb0f1b9 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -910,7 +910,97 @@ LEFT JOIN users ON connection_logs.user_id = users.id JOIN organizations ON connection_logs.organization_id = organizations.id -WHERE TRUE +WHERE + -- Filter organization_id + CASE + WHEN $1 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + connection_logs.organization_id = $1 + ELSE true + END + -- Filter by workspace owner username + AND CASE + WHEN $2 :: text != '' THEN + workspace_owner_id = ( + SELECT id FROM users + WHERE lower(username) = lower($2) AND deleted = false + ) + ELSE true + END + -- Filter by workspace_owner_id + AND CASE + WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + workspace_owner_id = $3 + ELSE true + END + -- Filter by workspace_owner_email + AND CASE + WHEN $4 :: text != '' THEN + workspace_owner_id = ( + SELECT id FROM users + WHERE email = $4 AND deleted = false + ) + ELSE true + END + -- Filter by type + AND CASE + WHEN $5 :: text != '' THEN + type = $5 :: connection_type + ELSE true + END + -- Filter by user_id + AND CASE + WHEN $6 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + user_id = $6 + ELSE true + END + -- Filter by username + AND CASE + WHEN $7 :: text != '' THEN + user_id = ( + SELECT id FROM users + WHERE lower(username) = lower($7) AND deleted = false + ) + ELSE true + END + -- Filter by user_email + AND CASE + WHEN $8 :: text != '' THEN + users.email = $8 + ELSE true + END + -- Filter by connected_after + AND CASE + WHEN $9 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + connect_time >= $9 + ELSE true + END + -- Filter by connected_before + AND CASE + WHEN $10 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + connect_time <= $10 + ELSE true + END + -- Filter by workspace_id + AND CASE + WHEN $11 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + connection_logs.workspace_id = $11 + ELSE true + END + -- Filter by connection_id + AND CASE + WHEN $12 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + connection_logs.connection_id = $12 + ELSE true + END + -- Filter by whether the session has a disconnect_time + AND CASE + WHEN $13 :: text != '' THEN + (($13 = 'ongoing' AND disconnect_time IS NULL) OR + ($13 = 'completed' AND disconnect_time IS NOT NULL)) AND + -- Exclude web events, since we don't know their close time. + "type" NOT IN ('workspace_app', 'port_forwarding') + ELSE true + END -- Authorize Filter clause will be injected below in -- GetAuthorizedConnectionLogsOffset -- @authorize_filter @@ -920,14 +1010,27 @@ LIMIT -- a limit of 0 means "no limit". The connection log table is unbounded -- in size, and is expected to be quite large. Implement a default -- limit of 100 to prevent accidental excessively large queries. - COALESCE(NULLIF($2 :: int, 0), 100) + COALESCE(NULLIF($15 :: int, 0), 100) OFFSET - $1 + $14 ` type GetConnectionLogsOffsetParams struct { - OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` - LimitOpt int32 `db:"limit_opt" json:"limit_opt"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + WorkspaceOwner string `db:"workspace_owner" json:"workspace_owner"` + WorkspaceOwnerID uuid.UUID `db:"workspace_owner_id" json:"workspace_owner_id"` + WorkspaceOwnerEmail string `db:"workspace_owner_email" json:"workspace_owner_email"` + Type string `db:"type" json:"type"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + Username string `db:"username" json:"username"` + UserEmail string `db:"user_email" json:"user_email"` + ConnectedAfter time.Time `db:"connected_after" json:"connected_after"` + ConnectedBefore time.Time `db:"connected_before" json:"connected_before"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + ConnectionID uuid.UUID `db:"connection_id" json:"connection_id"` + Status string `db:"status" json:"status"` + OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` + LimitOpt int32 `db:"limit_opt" json:"limit_opt"` } type GetConnectionLogsOffsetRow struct { @@ -951,7 +1054,23 @@ type GetConnectionLogsOffsetRow struct { } func (q *sqlQuerier) GetConnectionLogsOffset(ctx context.Context, arg GetConnectionLogsOffsetParams) ([]GetConnectionLogsOffsetRow, error) { - rows, err := q.db.QueryContext(ctx, getConnectionLogsOffset, arg.OffsetOpt, arg.LimitOpt) + rows, err := q.db.QueryContext(ctx, getConnectionLogsOffset, + arg.OrganizationID, + arg.WorkspaceOwner, + arg.WorkspaceOwnerID, + arg.WorkspaceOwnerEmail, + arg.Type, + arg.UserID, + arg.Username, + arg.UserEmail, + arg.ConnectedAfter, + arg.ConnectedBefore, + arg.WorkspaceID, + arg.ConnectionID, + arg.Status, + arg.OffsetOpt, + arg.LimitOpt, + ) if err != nil { return nil, err } diff --git a/coderd/database/queries/connectionlogs.sql b/coderd/database/queries/connectionlogs.sql index 172a7c533d7d5..e3f231a6b738e 100644 --- a/coderd/database/queries/connectionlogs.sql +++ b/coderd/database/queries/connectionlogs.sql @@ -28,7 +28,97 @@ LEFT JOIN users ON connection_logs.user_id = users.id JOIN organizations ON connection_logs.organization_id = organizations.id -WHERE TRUE +WHERE + -- Filter organization_id + CASE + WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + connection_logs.organization_id = @organization_id + ELSE true + END + -- Filter by workspace owner username + AND CASE + WHEN @workspace_owner :: text != '' THEN + workspace_owner_id = ( + SELECT id FROM users + WHERE lower(username) = lower(@workspace_owner) AND deleted = false + ) + ELSE true + END + -- Filter by workspace_owner_id + AND CASE + WHEN @workspace_owner_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + workspace_owner_id = @workspace_owner_id + ELSE true + END + -- Filter by workspace_owner_email + AND CASE + WHEN @workspace_owner_email :: text != '' THEN + workspace_owner_id = ( + SELECT id FROM users + WHERE email = @workspace_owner_email AND deleted = false + ) + ELSE true + END + -- Filter by type + AND CASE + WHEN @type :: text != '' THEN + type = @type :: connection_type + ELSE true + END + -- Filter by user_id + AND CASE + WHEN @user_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + user_id = @user_id + ELSE true + END + -- Filter by username + AND CASE + WHEN @username :: text != '' THEN + user_id = ( + SELECT id FROM users + WHERE lower(username) = lower(@username) AND deleted = false + ) + ELSE true + END + -- Filter by user_email + AND CASE + WHEN @user_email :: text != '' THEN + users.email = @user_email + ELSE true + END + -- Filter by connected_after + AND CASE + WHEN @connected_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + connect_time >= @connected_after + ELSE true + END + -- Filter by connected_before + AND CASE + WHEN @connected_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + connect_time <= @connected_before + ELSE true + END + -- Filter by workspace_id + AND CASE + WHEN @workspace_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + connection_logs.workspace_id = @workspace_id + ELSE true + END + -- Filter by connection_id + AND CASE + WHEN @connection_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + connection_logs.connection_id = @connection_id + ELSE true + END + -- Filter by whether the session has a disconnect_time + AND CASE + WHEN @status :: text != '' THEN + ((@status = 'ongoing' AND disconnect_time IS NULL) OR + (@status = 'completed' AND disconnect_time IS NOT NULL)) AND + -- Exclude web events, since we don't know their close time. + "type" NOT IN ('workspace_app', 'port_forwarding') + ELSE true + END -- Authorize Filter clause will be injected below in -- GetAuthorizedConnectionLogsOffset -- @authorize_filter diff --git a/coderd/members.go b/coderd/members.go index 5a031fe7eab90..0bd5bb1fbc8bd 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -195,7 +195,7 @@ func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() organization = httpmw.OrganizationParam(r) - paginationParams, ok = parsePagination(rw, r) + paginationParams, ok = ParsePagination(rw, r) ) if !ok { return diff --git a/coderd/pagination.go b/coderd/pagination.go index 0d01220d195e7..011f8df9e7bd4 100644 --- a/coderd/pagination.go +++ b/coderd/pagination.go @@ -9,9 +9,9 @@ import ( "github.com/coder/coder/v2/codersdk" ) -// parsePagination extracts pagination query params from the http request. +// ParsePagination extracts pagination query params from the http request. // If an error is encountered, the error is written to w and ok is set to false. -func parsePagination(w http.ResponseWriter, r *http.Request) (p codersdk.Pagination, ok bool) { +func ParsePagination(w http.ResponseWriter, r *http.Request) (p codersdk.Pagination, ok bool) { ctx := r.Context() queryParams := r.URL.Query() parser := httpapi.NewQueryParamParser() diff --git a/coderd/pagination_internal_test.go b/coderd/pagination_test.go similarity index 96% rename from coderd/pagination_internal_test.go rename to coderd/pagination_test.go index 18d98c2fab319..f6e1aab7067f4 100644 --- a/coderd/pagination_internal_test.go +++ b/coderd/pagination_test.go @@ -1,4 +1,4 @@ -package coderd +package coderd_test import ( "context" @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/codersdk" ) @@ -123,7 +124,7 @@ func TestPagination(t *testing.T) { query.Set("offset", c.Offset) r.URL.RawQuery = query.Encode() - params, ok := parsePagination(rw, r) + params, ok := coderd.ParsePagination(rw, r) if c.ExpectedError == "" { require.True(t, ok, "expect ok") require.Equal(t, c.ExpectedParams, params, "expected params") diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 634d4b6632ed3..c17b3db77bdc5 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -86,6 +86,46 @@ func AuditLogs(ctx context.Context, db database.Store, query string) (database.G return filter, countFilter, parser.Errors } +func ConnectionLogs(ctx context.Context, db database.Store, query string, apiKey database.APIKey) (database.GetConnectionLogsOffsetParams, []codersdk.ValidationError) { + // Always lowercase for all searches. + query = strings.ToLower(query) + values, errors := searchTerms(query, func(term string, values url.Values) error { + values.Add("search", term) + return nil + }) + if len(errors) > 0 { + return database.GetConnectionLogsOffsetParams{}, errors + } + + parser := httpapi.NewQueryParamParser() + filter := database.GetConnectionLogsOffsetParams{ + OrganizationID: parseOrganization(ctx, db, parser, values, "organization"), + WorkspaceOwner: parser.String(values, "", "workspace_owner"), + WorkspaceOwnerEmail: parser.String(values, "", "workspace_owner_email"), + Type: string(httpapi.ParseCustom(parser, values, "", "type", httpapi.ParseEnum[database.ConnectionType])), + Username: parser.String(values, "", "username"), + UserEmail: parser.String(values, "", "user_email"), + ConnectedAfter: parser.Time3339Nano(values, time.Time{}, "connected_after"), + ConnectedBefore: parser.Time3339Nano(values, time.Time{}, "connected_before"), + WorkspaceID: parser.UUID(values, uuid.Nil, "workspace_id"), + ConnectionID: parser.UUID(values, uuid.Nil, "connection_id"), + Status: string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[codersdk.ConnectionLogStatus])), + } + + if filter.Username == "me" { + filter.UserID = apiKey.UserID + filter.Username = "" + } + + if filter.WorkspaceOwner == "me" { + filter.WorkspaceOwnerID = apiKey.UserID + filter.WorkspaceOwner = "" + } + + parser.ErrorExcessParams(values) + return filter, parser.Errors +} + func Users(query string) (database.GetUsersParams, []codersdk.ValidationError) { // Always lowercase for all searches. query = strings.ToLower(query) diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index ad5f2df966ef9..c251a4cd5bd90 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -408,6 +408,72 @@ func TestSearchAudit(t *testing.T) { } } +func TestSearchConnectionLogs(t *testing.T) { + t.Parallel() + t.Run("All", func(t *testing.T) { + t.Parallel() + + orgID := uuid.New() + workspaceOwnerID := uuid.New() + workspaceID := uuid.New() + connectionID := uuid.New() + + db, _ := dbtestutil.NewDB(t) + dbgen.Organization(t, db, database.Organization{ + ID: orgID, + Name: "testorg", + }) + dbgen.User(t, db, database.User{ + ID: workspaceOwnerID, + Username: "testowner", + Email: "owner@example.com", + }) + + query := fmt.Sprintf(`organization:testorg workspace_owner:testowner `+ + `workspace_owner_email:owner@example.com type:port_forwarding username:testuser `+ + `user_email:test@example.com connected_after:"2023-01-01T00:00:00Z" `+ + `connected_before:"2023-01-16T12:00:00+12:00" workspace_id:%s connection_id:%s status:ongoing`, + workspaceID.String(), connectionID.String()) + + values, errs := searchquery.ConnectionLogs(context.Background(), db, query, database.APIKey{}) + require.Len(t, errs, 0) + + expected := database.GetConnectionLogsOffsetParams{ + OrganizationID: orgID, + WorkspaceOwner: "testowner", + WorkspaceOwnerEmail: "owner@example.com", + Type: string(database.ConnectionTypePortForwarding), + Username: "testuser", + UserEmail: "test@example.com", + ConnectedAfter: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), + ConnectedBefore: time.Date(2023, 1, 16, 0, 0, 0, 0, time.UTC), + WorkspaceID: workspaceID, + ConnectionID: connectionID, + Status: string(codersdk.ConnectionLogStatusOngoing), + } + + require.Equal(t, expected, values) + }) + + t.Run("Me", func(t *testing.T) { + t.Parallel() + + userID := uuid.New() + db, _ := dbtestutil.NewDB(t) + + query := `username:me workspace_owner:me` + values, errs := searchquery.ConnectionLogs(context.Background(), db, query, database.APIKey{UserID: userID}) + require.Len(t, errs, 0) + + expected := database.GetConnectionLogsOffsetParams{ + UserID: userID, + WorkspaceOwnerID: userID, + } + + require.Equal(t, expected, values) + }) +} + func TestSearchUsers(t *testing.T) { t.Parallel() testCases := []struct { diff --git a/coderd/templateversions.go b/coderd/templateversions.go index fa5a7ed1fe757..de069b5ca4723 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -807,7 +807,7 @@ func (api *API) templateVersionsByTemplate(rw http.ResponseWriter, r *http.Reque ctx := r.Context() template := httpmw.TemplateParam(r) - paginationParams, ok := parsePagination(rw, r) + paginationParams, ok := ParsePagination(rw, r) if !ok { return } diff --git a/coderd/users.go b/coderd/users.go index e2f6fd79c7d75..7fbb8e7d04cdf 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -290,7 +290,7 @@ func (api *API) GetUsers(rw http.ResponseWriter, r *http.Request) ([]database.Us return nil, -1, false } - paginationParams, ok := parsePagination(rw, r) + paginationParams, ok := ParsePagination(rw, r) if !ok { return nil, -1, false } diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index c8b1008280b09..88774c63368ca 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -119,7 +119,7 @@ func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() workspace := httpmw.WorkspaceParam(r) - paginationParams, ok := parsePagination(rw, r) + paginationParams, ok := ParsePagination(rw, r) if !ok { return } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index ecb624d1bc09f..05eae8f5145e6 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -146,7 +146,7 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) - page, ok := parsePagination(rw, r) + page, ok := ParsePagination(rw, r) if !ok { return } diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index f44c19b998e21..a78ee3c5608dd 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -37,18 +37,6 @@ import ( // log-source. This should be removed in the future. 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 // Coder API from a workspace agent. func New(serverURL *url.URL) *Client { diff --git a/codersdk/connectionlog.go b/codersdk/connectionlog.go new file mode 100644 index 0000000000000..9dd78694b4e08 --- /dev/null +++ b/codersdk/connectionlog.go @@ -0,0 +1,126 @@ +package codersdk + +import ( + "context" + "encoding/json" + "net/http" + "net/netip" + "strings" + "time" + + "github.com/google/uuid" +) + +type ConnectionLog struct { + ID uuid.UUID `json:"id" format:"uuid"` + ConnectTime time.Time `json:"connect_time" format:"date-time"` + Organization MinimalOrganization `json:"organization"` + WorkspaceOwnerID uuid.UUID `json:"workspace_owner_id" format:"uuid"` + WorkspaceOwnerUsername string `json:"workspace_owner_username"` + WorkspaceID uuid.UUID `json:"workspace_id" format:"uuid"` + WorkspaceName string `json:"workspace_name"` + AgentName string `json:"agent_name"` + IP netip.Addr `json:"ip"` + Type ConnectionType `json:"type"` + + // WebInfo is only set when `type` is one of: + // - `ConnectionTypePortForwarding` + // - `ConnectionTypeWorkspaceApp` + WebInfo *ConnectionLogWebInfo `json:"web_info,omitempty"` + + // SSHInfo is only set when `type` is one of: + // - `ConnectionTypeSSH` + // - `ConnectionTypeReconnectingPTY` + // - `ConnectionTypeVSCode` + // - `ConnectionTypeJetBrains` + SSHInfo *ConnectionLogSSHInfo `json:"ssh_info,omitempty"` +} + +// ConnectionType is the type of connection that the agent is receiving. +type ConnectionType string + +const ( + ConnectionTypeSSH ConnectionType = "ssh" + ConnectionTypeVSCode ConnectionType = "vscode" + ConnectionTypeJetBrains ConnectionType = "jetbrains" + ConnectionTypeReconnectingPTY ConnectionType = "reconnecting_pty" + ConnectionTypeWorkspaceApp ConnectionType = "workspace_app" + ConnectionTypePortForwarding ConnectionType = "port_forwarding" +) + +// ConnectionLogStatus is the status of a connection log entry. +// It's the argument to the `status` filter when fetching connection logs. +type ConnectionLogStatus string + +const ( + ConnectionLogStatusOngoing ConnectionLogStatus = "ongoing" + ConnectionLogStatusCompleted ConnectionLogStatus = "completed" +) + +func (s ConnectionLogStatus) Valid() bool { + switch s { + case ConnectionLogStatusOngoing, ConnectionLogStatusCompleted: + return true + default: + return false + } +} + +type ConnectionLogWebInfo struct { + UserAgent string `json:"user_agent"` + // User is omitted if the connection event was from an unauthenticated user. + User *User `json:"user"` + SlugOrPort string `json:"slug_or_port"` + // StatusCode is the HTTP status code of the request. + StatusCode int32 `json:"status_code"` +} + +type ConnectionLogSSHInfo struct { + ConnectionID uuid.UUID `json:"connection_id" format:"uuid"` + // DisconnectTime is omitted if a disconnect event with the same connection ID + // has not yet been seen. + DisconnectTime *time.Time `json:"disconnect_time,omitempty" format:"date-time"` + // DisconnectReason is omitted if a disconnect event with the same connection ID + // has not yet been seen. + DisconnectReason string `json:"disconnect_reason,omitempty"` + // ExitCode is the exit code of the SSH session. It is omitted if a + // disconnect event with the same connection ID has not yet been seen. + ExitCode *int32 `json:"exit_code,omitempty"` +} + +type ConnectionLogsRequest struct { + SearchQuery string `json:"q,omitempty"` + Pagination +} + +type ConnectionLogResponse struct { + ConnectionLogs []ConnectionLog `json:"connection_logs"` + Count int64 `json:"count"` +} + +func (c *Client) ConnectionLogs(ctx context.Context, req ConnectionLogsRequest) (ConnectionLogResponse, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/connectionlog", nil, req.Pagination.asRequestOption(), func(r *http.Request) { + q := r.URL.Query() + var params []string + if req.SearchQuery != "" { + params = append(params, req.SearchQuery) + } + q.Set("q", strings.Join(params, " ")) + r.URL.RawQuery = q.Encode() + }) + if err != nil { + return ConnectionLogResponse{}, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return ConnectionLogResponse{}, ReadBodyAsError(res) + } + + var logRes ConnectionLogResponse + err = json.NewDecoder(res.Body).Decode(&logRes) + if err != nil { + return ConnectionLogResponse{}, err + } + return logRes, nil +} diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 70821aa64f063..38e22bd85e277 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -207,6 +207,98 @@ curl -X PUT http://coder-server:8080/api/v2/appearance \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get connection logs + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/connectionlog?limit=0 \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /connectionlog` + +### Parameters + +| Name | In | Type | Required | Description | +|----------|-------|---------|----------|--------------| +| `q` | query | string | false | Search query | +| `limit` | query | integer | true | Page limit | +| `offset` | query | integer | false | Page offset | + +### Example responses + +> 200 Response + +```json +{ + "connection_logs": [ + { + "agent_name": "string", + "connect_time": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "ip": "string", + "organization": { + "display_name": "string", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string" + }, + "ssh_info": { + "connection_id": "d3547de1-d1f2-4344-b4c2-17169b7526f9", + "disconnect_reason": "string", + "disconnect_time": "2019-08-24T14:15:22Z", + "exit_code": 0 + }, + "type": "ssh", + "web_info": { + "slug_or_port": "string", + "status_code": 0, + "user": { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "organization_ids": [ + "497f6eca-6276-4993-bfeb-53cbbbba6f08" + ], + "roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + }, + "user_agent": "string" + }, + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9", + "workspace_name": "string", + "workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7", + "workspace_owner_username": "string" + } + ], + "count": 0 +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ConnectionLogResponse](schemas.md#codersdkconnectionlogresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get entitlements ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 3788d97753457..0000d93548008 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1085,6 +1085,228 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `p50` | number | false | | | | `p95` | number | false | | | +## codersdk.ConnectionLog + +```json +{ + "agent_name": "string", + "connect_time": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "ip": "string", + "organization": { + "display_name": "string", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string" + }, + "ssh_info": { + "connection_id": "d3547de1-d1f2-4344-b4c2-17169b7526f9", + "disconnect_reason": "string", + "disconnect_time": "2019-08-24T14:15:22Z", + "exit_code": 0 + }, + "type": "ssh", + "web_info": { + "slug_or_port": "string", + "status_code": 0, + "user": { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "organization_ids": [ + "497f6eca-6276-4993-bfeb-53cbbbba6f08" + ], + "roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + }, + "user_agent": "string" + }, + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9", + "workspace_name": "string", + "workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7", + "workspace_owner_username": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------------------|----------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------| +| `agent_name` | string | false | | | +| `connect_time` | string | false | | | +| `id` | string | false | | | +| `ip` | string | false | | | +| `organization` | [codersdk.MinimalOrganization](#codersdkminimalorganization) | false | | | +| `ssh_info` | [codersdk.ConnectionLogSSHInfo](#codersdkconnectionlogsshinfo) | false | | Ssh info is only set when `type` is one of: - `ConnectionTypeSSH` - `ConnectionTypeReconnectingPTY` - `ConnectionTypeVSCode` - `ConnectionTypeJetBrains` | +| `type` | [codersdk.ConnectionType](#codersdkconnectiontype) | false | | | +| `web_info` | [codersdk.ConnectionLogWebInfo](#codersdkconnectionlogwebinfo) | false | | Web info is only set when `type` is one of: - `ConnectionTypePortForwarding` - `ConnectionTypeWorkspaceApp` | +| `workspace_id` | string | false | | | +| `workspace_name` | string | false | | | +| `workspace_owner_id` | string | false | | | +| `workspace_owner_username` | string | false | | | + +## codersdk.ConnectionLogResponse + +```json +{ + "connection_logs": [ + { + "agent_name": "string", + "connect_time": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "ip": "string", + "organization": { + "display_name": "string", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string" + }, + "ssh_info": { + "connection_id": "d3547de1-d1f2-4344-b4c2-17169b7526f9", + "disconnect_reason": "string", + "disconnect_time": "2019-08-24T14:15:22Z", + "exit_code": 0 + }, + "type": "ssh", + "web_info": { + "slug_or_port": "string", + "status_code": 0, + "user": { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "organization_ids": [ + "497f6eca-6276-4993-bfeb-53cbbbba6f08" + ], + "roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + }, + "user_agent": "string" + }, + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9", + "workspace_name": "string", + "workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7", + "workspace_owner_username": "string" + } + ], + "count": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------------|-----------------------------------------------------------|----------|--------------|-------------| +| `connection_logs` | array of [codersdk.ConnectionLog](#codersdkconnectionlog) | false | | | +| `count` | integer | false | | | + +## codersdk.ConnectionLogSSHInfo + +```json +{ + "connection_id": "d3547de1-d1f2-4344-b4c2-17169b7526f9", + "disconnect_reason": "string", + "disconnect_time": "2019-08-24T14:15:22Z", + "exit_code": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------------|---------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------| +| `connection_id` | string | false | | | +| `disconnect_reason` | string | false | | Disconnect reason is omitted if a disconnect event with the same connection ID has not yet been seen. | +| `disconnect_time` | string | false | | Disconnect time is omitted if a disconnect event with the same connection ID has not yet been seen. | +| `exit_code` | integer | false | | Exit code is the exit code of the SSH session. It is omitted if a disconnect event with the same connection ID has not yet been seen. | + +## codersdk.ConnectionLogWebInfo + +```json +{ + "slug_or_port": "string", + "status_code": 0, + "user": { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "organization_ids": [ + "497f6eca-6276-4993-bfeb-53cbbbba6f08" + ], + "roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + }, + "user_agent": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------|--------------------------------|----------|--------------|---------------------------------------------------------------------------| +| `slug_or_port` | string | false | | | +| `status_code` | integer | false | | Status code is the HTTP status code of the request. | +| `user` | [codersdk.User](#codersdkuser) | false | | User is omitted if the connection event was from an unauthenticated user. | +| `user_agent` | string | false | | | + +## codersdk.ConnectionType + +```json +"ssh" +``` + +### Properties + +#### Enumerated Values + +| Value | +|--------------------| +| `ssh` | +| `vscode` | +| `jetbrains` | +| `reconnecting_pty` | +| `workspace_app` | +| `port_forwarding` | + ## codersdk.ConvertLoginRequest ```json diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 6d523e9226b88..0d176567713a2 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -226,6 +226,13 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Use(apiKeyMiddleware) r.Get("/", api.replicas) }) + r.Route("/connectionlog", func(r chi.Router) { + r.Use( + apiKeyMiddleware, + api.RequireFeatureMW(codersdk.FeatureConnectionLog), + ) + r.Get("/", api.connectionLogs) + }) r.Route("/licenses", func(r chi.Router) { r.Use(apiKeyMiddleware) r.Post("/refresh-entitlements", api.postRefreshEntitlements) diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index e4088e83d09f5..54dcb9c582628 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -59,6 +59,7 @@ func init() { type Options struct { *coderdtest.Options + ConnectionLogging bool AuditLogging bool BrowserOnly bool EntitlementsUpdateInterval time.Duration @@ -100,6 +101,7 @@ func NewWithAPI(t *testing.T, options *Options) ( setHandler, cancelFunc, serverURL, oop := coderdtest.NewOptions(t, options.Options) coderAPI, err := coderd.New(context.Background(), &coderd.Options{ RBAC: true, + ConnectionLogging: options.ConnectionLogging, AuditLogging: options.AuditLogging, BrowserOnly: options.BrowserOnly, SCIMAPIKey: options.SCIMAPIKey, diff --git a/enterprise/coderd/connectionlog.go b/enterprise/coderd/connectionlog.go new file mode 100644 index 0000000000000..75413b82708fb --- /dev/null +++ b/enterprise/coderd/connectionlog.go @@ -0,0 +1,149 @@ +package coderd + +import ( + "net/http" + "net/netip" + + "github.com/google/uuid" + + agpl "github.com/coder/coder/v2/coderd" + "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/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/searchquery" + "github.com/coder/coder/v2/codersdk" +) + +// @Summary Get connection logs +// @ID get-connection-logs +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param q query string false "Search query" +// @Param limit query int true "Page limit" +// @Param offset query int false "Page offset" +// @Success 200 {object} codersdk.ConnectionLogResponse +// @Router /connectionlog [get] +func (api *API) connectionLogs(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + + page, ok := agpl.ParsePagination(rw, r) + if !ok { + return + } + + queryStr := r.URL.Query().Get("q") + filter, errs := searchquery.ConnectionLogs(ctx, api.Database, queryStr, apiKey) + if len(errs) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid connection search query.", + Validations: errs, + }) + return + } + // #nosec G115 - Safe conversion as pagination offset is expected to be within int32 range + filter.OffsetOpt = int32(page.Offset) + // #nosec G115 - Safe conversion as pagination limit is expected to be within int32 range + filter.LimitOpt = int32(page.Limit) + + dblogs, err := api.Database.GetConnectionLogsOffset(ctx, filter) + if dbauthz.IsNotAuthorizedError(err) { + httpapi.Forbidden(rw) + return + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.ConnectionLogResponse{ + ConnectionLogs: convertConnectionLogs(dblogs), + Count: 0, // TODO(ethanndickson): Set count + }) +} + +func convertConnectionLogs(dblogs []database.GetConnectionLogsOffsetRow) []codersdk.ConnectionLog { + clogs := make([]codersdk.ConnectionLog, 0, len(dblogs)) + + for _, dblog := range dblogs { + clogs = append(clogs, convertConnectionLog(dblog)) + } + return clogs +} + +func convertConnectionLog(dblog database.GetConnectionLogsOffsetRow) codersdk.ConnectionLog { + ip, _ := netip.AddrFromSlice(dblog.ConnectionLog.Ip.IPNet.IP) + + var user *codersdk.User + if dblog.ConnectionLog.UserID.Valid { + sdkUser := db2sdk.User(database.User{ + ID: dblog.ConnectionLog.UserID.UUID, + Email: dblog.UserEmail.String, + Username: dblog.UserUsername.String, + CreatedAt: dblog.UserCreatedAt.Time, + UpdatedAt: dblog.UserUpdatedAt.Time, + Status: dblog.UserStatus.UserStatus, + RBACRoles: dblog.UserRoles, + LoginType: dblog.UserLoginType.LoginType, + AvatarURL: dblog.UserAvatarUrl.String, + Deleted: dblog.UserDeleted.Bool, + LastSeenAt: dblog.UserLastSeenAt.Time, + QuietHoursSchedule: dblog.UserQuietHoursSchedule.String, + Name: dblog.UserName.String, + }, []uuid.UUID{}) + user = &sdkUser + } + + var ( + webInfo *codersdk.ConnectionLogWebInfo + sshInfo *codersdk.ConnectionLogSSHInfo + ) + + switch dblog.ConnectionLog.Type { + case database.ConnectionTypeWorkspaceApp, + database.ConnectionTypePortForwarding: + webInfo = &codersdk.ConnectionLogWebInfo{ + UserAgent: dblog.ConnectionLog.UserAgent.String, + User: user, + SlugOrPort: dblog.ConnectionLog.SlugOrPort.String, + StatusCode: dblog.ConnectionLog.Code.Int32, + } + case database.ConnectionTypeSsh, + database.ConnectionTypeReconnectingPty, + database.ConnectionTypeJetbrains, + database.ConnectionTypeVscode: + sshInfo = &codersdk.ConnectionLogSSHInfo{ + ConnectionID: dblog.ConnectionLog.ConnectionID.UUID, + DisconnectReason: dblog.ConnectionLog.DisconnectReason.String, + } + if dblog.ConnectionLog.DisconnectTime.Valid { + sshInfo.DisconnectTime = &dblog.ConnectionLog.DisconnectTime.Time + } + if dblog.ConnectionLog.Code.Valid { + sshInfo.ExitCode = &dblog.ConnectionLog.Code.Int32 + } + } + + return codersdk.ConnectionLog{ + ID: dblog.ConnectionLog.ID, + ConnectTime: dblog.ConnectionLog.ConnectTime, + Organization: codersdk.MinimalOrganization{ + ID: dblog.ConnectionLog.OrganizationID, + Name: dblog.OrganizationName, + DisplayName: dblog.OrganizationDisplayName, + Icon: dblog.OrganizationIcon, + }, + WorkspaceOwnerID: dblog.ConnectionLog.WorkspaceOwnerID, + WorkspaceOwnerUsername: dblog.WorkspaceOwnerUsername, + WorkspaceID: dblog.ConnectionLog.WorkspaceID, + WorkspaceName: dblog.ConnectionLog.WorkspaceName, + AgentName: dblog.ConnectionLog.AgentName, + Type: codersdk.ConnectionType(dblog.ConnectionLog.Type), + IP: ip, + WebInfo: webInfo, + SSHInfo: sshInfo, + } +} diff --git a/enterprise/coderd/connectionlog_test.go b/enterprise/coderd/connectionlog_test.go new file mode 100644 index 0000000000000..b94b2449f37c4 --- /dev/null +++ b/enterprise/coderd/connectionlog_test.go @@ -0,0 +1,251 @@ +package coderd_test + +import ( + "context" + "database/sql" + "fmt" + "net" + "testing" + "time" + + "github.com/google/uuid" + "github.com/sqlc-dev/pqtype" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" +) + +func TestConnectionLogs(t *testing.T) { + t.Parallel() + + createWorkspace := func(t *testing.T, db database.Store) database.WorkspaceTable { + u := dbgen.User(t, db, database.User{}) + o := dbgen.Organization(t, db, database.Organization{}) + tpl := dbgen.Template(t, db, database.Template{ + OrganizationID: o.ID, + CreatedBy: u.ID, + }) + return dbgen.Workspace(t, db, database.WorkspaceTable{ + ID: uuid.New(), + OwnerID: u.ID, + OrganizationID: o.ID, + AutomaticUpdates: database.AutomaticUpdatesNever, + TemplateID: tpl.ID, + }) + } + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client, db, _ := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + ConnectionLogging: true, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAuditLog: 1, + codersdk.FeatureConnectionLog: 1, + }, + }, + }) + + ws := createWorkspace(t, db) + _ = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Type: database.ConnectionTypeSsh, + WorkspaceID: ws.ID, + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, + }) + + logs, err := client.ConnectionLogs(ctx, codersdk.ConnectionLogsRequest{}) + require.NoError(t, err) + + require.Len(t, logs.ConnectionLogs, 1) + require.Equal(t, codersdk.ConnectionTypeSSH, logs.ConnectionLogs[0].Type) + }) + + t.Run("Empty", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client, _, _ := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + ConnectionLogging: true, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAuditLog: 1, + codersdk.FeatureConnectionLog: 1, + }, + }, + }) + + logs, err := client.ConnectionLogs(ctx, codersdk.ConnectionLogsRequest{}) + require.NoError(t, err) + + require.Len(t, logs.ConnectionLogs, 0) + }) + + t.Run("ByOrganizationIDAndName", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client, db, _ := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + ConnectionLogging: true, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAuditLog: 1, + codersdk.FeatureConnectionLog: 1, + }, + }, + }) + + org := dbgen.Organization(t, db, database.Organization{}) + ws := createWorkspace(t, db) + _ = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Type: database.ConnectionTypeSsh, + WorkspaceID: ws.ID, + OrganizationID: org.ID, + WorkspaceOwnerID: ws.OwnerID, + }) + _ = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Type: database.ConnectionTypeSsh, + WorkspaceID: ws.ID, + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, + }) + + // By name + logs, err := client.ConnectionLogs(ctx, codersdk.ConnectionLogsRequest{ + SearchQuery: fmt.Sprintf("organization:%s", org.Name), + }) + require.NoError(t, err) + + require.Len(t, logs.ConnectionLogs, 1) + require.Equal(t, org.ID, logs.ConnectionLogs[0].Organization.ID) + + // By ID + logs, err = client.ConnectionLogs(ctx, codersdk.ConnectionLogsRequest{ + SearchQuery: fmt.Sprintf("organization:%s", ws.OrganizationID), + }) + require.NoError(t, err) + + require.Len(t, logs.ConnectionLogs, 1) + require.Equal(t, ws.OrganizationID, logs.ConnectionLogs[0].Organization.ID) + }) + + t.Run("WebInfo", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client, db, _ := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + ConnectionLogging: true, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAuditLog: 1, + codersdk.FeatureConnectionLog: 1, + }, + }, + }) + + now := dbtime.Now() + connID := uuid.New() + ws := createWorkspace(t, db) + clog := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Time: now.Add(-time.Hour), + Type: database.ConnectionTypeWorkspaceApp, + WorkspaceID: ws.ID, + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, + ConnectionID: uuid.NullUUID{UUID: connID, Valid: true}, + UserAgent: sql.NullString{String: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36", Valid: true}, + UserID: uuid.NullUUID{UUID: ws.OwnerID, Valid: true}, + SlugOrPort: sql.NullString{String: "code-server", Valid: true}, + }) + + logs, err := client.ConnectionLogs(ctx, codersdk.ConnectionLogsRequest{}) + require.NoError(t, err) + + require.Len(t, logs.ConnectionLogs, 1) + require.NotNil(t, logs.ConnectionLogs[0].WebInfo) + require.Equal(t, clog.SlugOrPort.String, logs.ConnectionLogs[0].WebInfo.SlugOrPort) + require.Equal(t, clog.UserAgent.String, logs.ConnectionLogs[0].WebInfo.UserAgent) + require.Equal(t, ws.OwnerID, logs.ConnectionLogs[0].WebInfo.User.ID) + }) + + t.Run("SSHInfo", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client, db, _ := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + ConnectionLogging: true, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAuditLog: 1, + codersdk.FeatureConnectionLog: 1, + }, + }, + }) + + now := dbtime.Now() + connID := uuid.New() + ws := createWorkspace(t, db) + clog := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Time: now.Add(-time.Hour), + Type: database.ConnectionTypeSsh, + WorkspaceID: ws.ID, + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, + ConnectionID: uuid.NullUUID{UUID: connID, Valid: true}, + }) + + logs, err := client.ConnectionLogs(ctx, codersdk.ConnectionLogsRequest{}) + require.NoError(t, err) + + require.Len(t, logs.ConnectionLogs, 1) + require.NotNil(t, logs.ConnectionLogs[0].SSHInfo) + require.Empty(t, logs.ConnectionLogs[0].WebInfo) + require.Empty(t, logs.ConnectionLogs[0].SSHInfo.ExitCode) + require.Empty(t, logs.ConnectionLogs[0].SSHInfo.DisconnectTime) + require.Empty(t, logs.ConnectionLogs[0].SSHInfo.DisconnectReason) + + // Mark log as closed + updatedClog := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Time: now, + OrganizationID: clog.OrganizationID, + Type: clog.Type, + WorkspaceID: clog.WorkspaceID, + WorkspaceOwnerID: clog.WorkspaceOwnerID, + WorkspaceName: clog.WorkspaceName, + AgentName: clog.AgentName, + Code: sql.NullInt32{ + Int32: 0, + Valid: false, + }, + Ip: pqtype.Inet{IPNet: net.IPNet{ + IP: net.ParseIP("192.168.0.1"), + Mask: net.CIDRMask(8, 32), + }, Valid: true}, + + ConnectionID: clog.ConnectionID, + ConnectionStatus: database.ConnectionStatusDisconnected, + DisconnectReason: sql.NullString{ + String: "example close reason", + Valid: true, + }, + }) + + logs, err = client.ConnectionLogs(ctx, codersdk.ConnectionLogsRequest{}) + require.NoError(t, err) + + require.Len(t, logs.ConnectionLogs, 1) + require.NotNil(t, logs.ConnectionLogs[0].SSHInfo) + require.Nil(t, logs.ConnectionLogs[0].WebInfo) + require.Equal(t, codersdk.ConnectionTypeSSH, logs.ConnectionLogs[0].Type) + require.Equal(t, clog.ConnectionID.UUID, logs.ConnectionLogs[0].SSHInfo.ConnectionID) + require.True(t, logs.ConnectionLogs[0].SSHInfo.DisconnectTime.Equal(now)) + require.Equal(t, updatedClog.DisconnectReason.String, logs.ConnectionLogs[0].SSHInfo.DisconnectReason) + }) +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 23a739df063de..0b6148f796f6b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -322,6 +322,75 @@ export interface ConnectionLatency { readonly p95: number; } +// From codersdk/connectionlog.go +export interface ConnectionLog { + readonly id: string; + readonly connect_time: string; + readonly organization: MinimalOrganization; + readonly workspace_owner_id: string; + readonly workspace_owner_username: string; + readonly workspace_id: string; + readonly workspace_name: string; + readonly agent_name: string; + readonly ip: string; + readonly type: ConnectionType; + readonly web_info?: ConnectionLogWebInfo; + readonly ssh_info?: ConnectionLogSSHInfo; +} + +// From codersdk/connectionlog.go +export interface ConnectionLogResponse { + readonly connection_logs: readonly ConnectionLog[]; + readonly count: number; +} + +// From codersdk/connectionlog.go +export interface ConnectionLogSSHInfo { + readonly connection_id: string; + readonly disconnect_time?: string; + readonly disconnect_reason?: string; + readonly exit_code?: number; +} + +// From codersdk/connectionlog.go +export type ConnectionLogStatus = "completed" | "ongoing"; + +export const ConnectionLogStatuses: ConnectionLogStatus[] = [ + "completed", + "ongoing", +]; + +// From codersdk/connectionlog.go +export interface ConnectionLogWebInfo { + readonly user_agent: string; + readonly user: User | null; + readonly slug_or_port: string; + readonly status_code: number; +} + +// From codersdk/connectionlog.go +export interface ConnectionLogsRequest extends Pagination { + readonly q?: string; +} + +// From codersdk/connectionlog.go +export type ConnectionType = + | "jetbrains" + | "port_forwarding" + | "reconnecting_pty" + | "ssh" + | "vscode" + | "workspace_app"; + +export const ConnectionTypes: ConnectionType[] = [ + "jetbrains", + "port_forwarding", + "reconnecting_pty", + "ssh", + "vscode", + "workspace_app", +]; + // From codersdk/files.go export const ContentTypeTar = "application/x-tar";