diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b95e78772e2..006b6e3d38e 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -18461,7 +18461,7 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.ConnectionType" }, "web_info": { - "description": "WebInfo is only set when ` + "`" + `type` + "`" + ` is one of:\n- ` + "`" + `ConnectionTypePortForwarding` + "`" + `\n- ` + "`" + `ConnectionTypeWorkspaceApp` + "`" + `", + "description": "WebInfo is only set when ` + "`" + `type` + "`" + ` is one of:\n- ` + "`" + `ConnectionTypePortForwarding` + "`" + `\n- ` + "`" + `ConnectionTypeWorkspaceApp` + "`" + `\n- ` + "`" + `ConnectionTypeTunnel` + "`" + `", "allOf": [ { "$ref": "#/definitions/codersdk.ConnectionLogWebInfo" @@ -18554,7 +18554,8 @@ const docTemplate = `{ "jetbrains", "reconnecting_pty", "workspace_app", - "port_forwarding" + "port_forwarding", + "tunnel" ], "x-enum-varnames": [ "ConnectionTypeSSH", @@ -18562,7 +18563,8 @@ const docTemplate = `{ "ConnectionTypeJetBrains", "ConnectionTypeReconnectingPTY", "ConnectionTypeWorkspaceApp", - "ConnectionTypePortForwarding" + "ConnectionTypePortForwarding", + "ConnectionTypeTunnel" ] }, "codersdk.ConvertLoginRequest": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 1a3f184e8d3..901c851ba63 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -16667,7 +16667,7 @@ "$ref": "#/definitions/codersdk.ConnectionType" }, "web_info": { - "description": "WebInfo is only set when `type` is one of:\n- `ConnectionTypePortForwarding`\n- `ConnectionTypeWorkspaceApp`", + "description": "WebInfo is only set when `type` is one of:\n- `ConnectionTypePortForwarding`\n- `ConnectionTypeWorkspaceApp`\n- `ConnectionTypeTunnel`", "allOf": [ { "$ref": "#/definitions/codersdk.ConnectionLogWebInfo" @@ -16760,7 +16760,8 @@ "jetbrains", "reconnecting_pty", "workspace_app", - "port_forwarding" + "port_forwarding", + "tunnel" ], "x-enum-varnames": [ "ConnectionTypeSSH", @@ -16768,7 +16769,8 @@ "ConnectionTypeJetBrains", "ConnectionTypeReconnectingPTY", "ConnectionTypeWorkspaceApp", - "ConnectionTypePortForwarding" + "ConnectionTypePortForwarding", + "ConnectionTypeTunnel" ] }, "codersdk.ConvertLoginRequest": { diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index d99062c3671..545cba9d220 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -379,7 +379,8 @@ CREATE TYPE connection_type AS ENUM ( 'jetbrains', 'reconnecting_pty', 'workspace_app', - 'port_forwarding' + 'port_forwarding', + 'tunnel' ); CREATE TYPE cors_behavior AS ENUM ( diff --git a/coderd/database/migrations/000557_connection_type_tunnel.down.sql b/coderd/database/migrations/000557_connection_type_tunnel.down.sql new file mode 100644 index 00000000000..217078407e0 --- /dev/null +++ b/coderd/database/migrations/000557_connection_type_tunnel.down.sql @@ -0,0 +1,8 @@ +-- The 'tunnel' enum value is intentionally not removed. Postgres cannot +-- drop an enum value in place; removing it would require recreating +-- connection_type and rewriting the connection_logs type column, which +-- takes an exclusive lock on the table and would have to DELETE all +-- tunnel rows (audit data) because they cannot exist in the old type. +-- Leaving the value in place is harmless: old code never queries for it +-- and renders unknown types without error. This matches the precedent +-- of other enum-value additions (e.g. 000517, 000531). diff --git a/coderd/database/migrations/000557_connection_type_tunnel.up.sql b/coderd/database/migrations/000557_connection_type_tunnel.up.sql new file mode 100644 index 00000000000..3a95492192d --- /dev/null +++ b/coderd/database/migrations/000557_connection_type_tunnel.up.sql @@ -0,0 +1 @@ +ALTER TYPE connection_type ADD VALUE IF NOT EXISTS 'tunnel'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 74e982f5fc4..56fb2e1e1f0 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1855,6 +1855,7 @@ const ( ConnectionTypeReconnectingPty ConnectionType = "reconnecting_pty" ConnectionTypeWorkspaceApp ConnectionType = "workspace_app" ConnectionTypePortForwarding ConnectionType = "port_forwarding" + ConnectionTypeTunnel ConnectionType = "tunnel" ) func (e *ConnectionType) Scan(src interface{}) error { @@ -1899,7 +1900,8 @@ func (e ConnectionType) Valid() bool { ConnectionTypeJetbrains, ConnectionTypeReconnectingPty, ConnectionTypeWorkspaceApp, - ConnectionTypePortForwarding: + ConnectionTypePortForwarding, + ConnectionTypeTunnel: return true } return false @@ -1913,6 +1915,7 @@ func AllConnectionTypeValues() []ConnectionType { ConnectionTypeReconnectingPty, ConnectionTypeWorkspaceApp, ConnectionTypePortForwarding, + ConnectionTypeTunnel, } } diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 5cc5c9bd1a7..40389c62d74 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -3997,6 +3997,20 @@ func TestConnectionLogsOffsetFilters(t *testing.T) { UserID: uuid.NullUUID{UUID: user3.ID, Valid: true}, }) + // Tunnel events are point-in-time (no disconnect event is ever + // reported), so despite having a NULL disconnect_time they must be + // excluded from both status filters. + log5 := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Time: now.Add(-30 * time.Minute), + OrganizationID: ws1.OrganizationID, + WorkspaceOwnerID: ws1.OwnerID, + WorkspaceID: ws1.ID, + WorkspaceName: ws1.Name, + Type: database.ConnectionTypeTunnel, + ConnectionStatus: database.ConnectionStatusConnected, + UserID: uuid.NullUUID{UUID: user1.ID, Valid: true}, + }) + testCases := []struct { name string params database.GetConnectionLogsOffsetParams @@ -4006,7 +4020,7 @@ func TestConnectionLogsOffsetFilters(t *testing.T) { name: "NoFilter", params: database.GetConnectionLogsOffsetParams{}, expectedLogIDs: []uuid.UUID{ - log1.ID, log2.ID, log3.ID, log4.ID, + log1.ID, log2.ID, log3.ID, log4.ID, log5.ID, }, }, { @@ -4021,14 +4035,14 @@ func TestConnectionLogsOffsetFilters(t *testing.T) { params: database.GetConnectionLogsOffsetParams{ WorkspaceOwner: user1.Username, }, - expectedLogIDs: []uuid.UUID{log1.ID, log2.ID}, + expectedLogIDs: []uuid.UUID{log1.ID, log2.ID, log5.ID}, }, { name: "WorkspaceOwnerID", params: database.GetConnectionLogsOffsetParams{ WorkspaceOwnerID: user1.ID, }, - expectedLogIDs: []uuid.UUID{log1.ID, log2.ID}, + expectedLogIDs: []uuid.UUID{log1.ID, log2.ID, log5.ID}, }, { name: "WorkspaceOwnerEmail", @@ -4044,19 +4058,26 @@ func TestConnectionLogsOffsetFilters(t *testing.T) { }, expectedLogIDs: []uuid.UUID{log2.ID, log4.ID}, }, + { + name: "TypeTunnel", + params: database.GetConnectionLogsOffsetParams{ + Type: string(database.ConnectionTypeTunnel), + }, + expectedLogIDs: []uuid.UUID{log5.ID}, + }, { name: "UserID", params: database.GetConnectionLogsOffsetParams{ UserID: user1.ID, }, - expectedLogIDs: []uuid.UUID{log1.ID}, + expectedLogIDs: []uuid.UUID{log1.ID, log5.ID}, }, { name: "Username", params: database.GetConnectionLogsOffsetParams{ Username: user1.Username, }, - expectedLogIDs: []uuid.UUID{log1.ID}, + expectedLogIDs: []uuid.UUID{log1.ID, log5.ID}, }, { name: "UserEmail", @@ -4070,7 +4091,7 @@ func TestConnectionLogsOffsetFilters(t *testing.T) { params: database.GetConnectionLogsOffsetParams{ ConnectedAfter: now.Add(-90 * time.Minute), // 1.5 hours ago }, - expectedLogIDs: []uuid.UUID{log4.ID}, + expectedLogIDs: []uuid.UUID{log4.ID, log5.ID}, }, { name: "ConnectedBefore", diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ee88e6cdba8..596ddd0a64f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -13742,8 +13742,9 @@ SELECT COUNT(*) AS count FROM ( 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') + -- Exclude point-in-time events reported by coderd, since we + -- don't know their close time. + "type" NOT IN ('workspace_app', 'port_forwarding', 'tunnel') ELSE true END -- Authorize Filter clause will be injected below in @@ -13936,8 +13937,9 @@ WHERE 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') + -- Exclude point-in-time events reported by coderd, since we + -- don't know their close time. + "type" NOT IN ('workspace_app', 'port_forwarding', 'tunnel') ELSE true END -- Authorize Filter clause will be injected below in diff --git a/coderd/database/queries/connectionlogs.sql b/coderd/database/queries/connectionlogs.sql index 7e5fb63a37b..605d8add64c 100644 --- a/coderd/database/queries/connectionlogs.sql +++ b/coderd/database/queries/connectionlogs.sql @@ -115,8 +115,9 @@ WHERE 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') + -- Exclude point-in-time events reported by coderd, since we + -- don't know their close time. + "type" NOT IN ('workspace_app', 'port_forwarding', 'tunnel') ELSE true END -- Authorize Filter clause will be injected below in @@ -230,8 +231,9 @@ SELECT COUNT(*) AS count FROM ( 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') + -- Exclude point-in-time events reported by coderd, since we + -- don't know their close time. + "type" NOT IN ('workspace_app', 'port_forwarding', 'tunnel') ELSE true END -- Authorize Filter clause will be injected below in diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 3a7fddfb61e..03791570806 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -1367,6 +1367,9 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R }) return } + + api.logTunnelConnection(ctx, r, waws) + ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageBinary) defer wsNetConn.Close() @@ -1386,6 +1389,96 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R } } +// logTunnelConnection records a connection log entry attributing a +// tunnel to the authenticated user who opened it. Agent-reported rows +// cannot identify the user (see coderd/agentapi/connectionlog.go), and +// workspace-proxy-authenticated requests carry no API key and are +// skipped. +func (api *API) logTunnelConnection(ctx context.Context, r *http.Request, waws database.GetWorkspaceAgentAndWorkspaceByIDRow) { + apiKey, ok := httpmw.APIKeyOptional(r) + if !ok { + return + } + // Bounded so log backpressure cannot stall tunnel establishment. + writeCtx, writeCancel := context.WithTimeout(ctx, 3*time.Second) + defer writeCancel() + userAgent := r.UserAgent() + now := dbtime.Now() + + // Clients re-dial automatically, so dedupe reconnects through the + // same audit session mechanism as workspace apps, keyed on + // (agent, user, IP, user agent). Status 101 and the empty slug + // keep tunnel sessions from ever colliding with app or + // port-forwarding sessions. + staleInterval := api.Options.WorkspaceAppAuditSessionTimeout + if staleInterval == 0 { + staleInterval = time.Hour + } + // nolint:gocritic // System context is needed to write audit sessions. + newSession, err := api.Database.UpsertWorkspaceAppAuditSession(dbauthz.AsSystemRestricted(writeCtx), database.UpsertWorkspaceAppAuditSessionParams{ + // Config. + StaleIntervalMS: staleInterval.Milliseconds(), + + // Data. + ID: uuid.New(), + AgentID: waws.WorkspaceAgent.ID, + AppID: uuid.Nil, // Tunnels are not associated with an app. + UserID: apiKey.UserID, + Ip: r.RemoteAddr, + UserAgent: userAgent, + SlugOrPort: "", + StatusCode: http.StatusSwitchingProtocols, + StartedAt: now, + UpdatedAt: now, + }) + if err != nil { + // Skip logging rather than risk spamming the connection log. + api.Logger.Error(ctx, "upsert tunnel audit session", + slog.F("workspace_id", waws.WorkspaceTable.ID), + slog.F("user_id", apiKey.UserID), + slog.Error(err), + ) + return + } + if !newSession { + // Reconnection of an already-logged session. + return + } + + connLogger := *api.ConnectionLogger.Load() + err = connLogger.Upsert(writeCtx, database.UpsertConnectionLogParams{ + ID: uuid.New(), + Time: now, + OrganizationID: waws.WorkspaceTable.OrganizationID, + WorkspaceOwnerID: waws.WorkspaceTable.OwnerID, + WorkspaceID: waws.WorkspaceTable.ID, + WorkspaceName: waws.WorkspaceTable.Name, + AgentName: waws.WorkspaceAgent.Name, + Type: database.ConnectionTypeTunnel, + IP: database.ParseIP(r.RemoteAddr), + Code: sql.NullInt32{ + Int32: http.StatusSwitchingProtocols, + Valid: true, + }, + UserAgent: sql.NullString{String: userAgent, Valid: userAgent != ""}, + UserID: uuid.NullUUID{UUID: apiKey.UserID, Valid: true}, + // Left unset so each session gets its own row; reusing peerID + // would make resume_token reconnects upsert into a stale row. + ConnectionID: uuid.NullUUID{}, + ConnectionStatus: database.ConnectionStatusConnected, + // N/A + SlugOrPort: sql.NullString{}, + DisconnectReason: sql.NullString{}, + }) + if err != nil { + api.Logger.Error(ctx, "upsert tunnel connection log", + slog.F("workspace_id", waws.WorkspaceTable.ID), + slog.F("user_id", apiKey.UserID), + slog.Error(err), + ) + } +} + // handleResumeToken accepts a resume_token query parameter to use the same peer ID func (api *API) handleResumeToken(ctx context.Context, rw http.ResponseWriter, r *http.Request) (peerID uuid.UUID, err error) { peerID = uuid.New() diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index f41921c7bb5..cfc4845d516 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -43,6 +43,7 @@ import ( "github.com/coder/coder/v2/coderd/agentapi/metadatabatcher" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/coderdtest/oidctest" + "github.com/coder/coder/v2/coderd/connectionlog" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbauthz" @@ -919,6 +920,77 @@ func TestWorkspaceAgentTailnet(t *testing.T) { require.Equal(t, "test", strings.TrimSpace(string(output))) } +func TestWorkspaceAgentClientCoordinate_ConnectionLog(t *testing.T) { + t.Parallel() + connLogger := connectionlog.NewFake() + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + ConnectionLogger: connLogger, + }) + user := coderdtest.CreateFirstUser(t, client) + + r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithAgent().Do() + + _ = agenttest.New(t, client.URL, r.AgentToken) + resources := coderdtest.AwaitWorkspaceAgents(t, client, r.Workspace.ID) + + ctx := testutil.Context(t, testutil.WaitLong) + + conn, err := workspacesdk.New(client). + DialAgent(ctx, resources[0].Agents[0].ID, &workspacesdk.DialAgentOptions{ + Logger: testutil.Logger(t).Named("client"), + }) + require.NoError(t, err) + defer conn.Close() + require.True(t, conn.AwaitReachable(ctx)) + + require.Eventually(t, func() bool { + return connLogger.Contains(t, database.UpsertConnectionLogParams{ + OrganizationID: user.OrganizationID, + WorkspaceOwnerID: user.UserID, + WorkspaceID: r.Workspace.ID, + WorkspaceName: r.Workspace.Name, + AgentName: resources[0].Agents[0].Name, + Type: database.ConnectionTypeTunnel, + Code: sql.NullInt32{ + Int32: http.StatusSwitchingProtocols, + Valid: true, + }, + ConnectionStatus: database.ConnectionStatusConnected, + UserID: uuid.NullUUID{ + UUID: user.UserID, + Valid: true, + }, + }) + }, testutil.WaitShort, testutil.IntervalFast) + err = conn.Close() + require.NoError(t, err) + + // A second handshake within the audit session stale interval is a + // reconnection and must be deduplicated rather than producing a + // second row. + conn2, err := workspacesdk.New(client). + DialAgent(ctx, resources[0].Agents[0].ID, &workspacesdk.DialAgentOptions{ + Logger: testutil.Logger(t).Named("client2"), + }) + require.NoError(t, err) + defer conn2.Close() + // The connection log write happens in the coordinate handler + // before any coordination traffic is served, so once the tunnel is + // reachable the second handshake has already been processed. + require.True(t, conn2.AwaitReachable(ctx)) + + tunnelRows := 0 + for _, cl := range connLogger.ConnectionLogs() { + if cl.Type == database.ConnectionTypeTunnel { + tunnelRows++ + } + } + require.Equal(t, 1, tunnelRows) +} + func TestWorkspaceAgentClientCoordinate_BadVersion(t *testing.T) { t.Parallel() client, db := coderdtest.NewWithDatabase(t, nil) diff --git a/coderd/workspaceapps/db.go b/coderd/workspaceapps/db.go index 36b11bee1ab..fbd69aa3f10 100644 --- a/coderd/workspaceapps/db.go +++ b/coderd/workspaceapps/db.go @@ -466,6 +466,20 @@ func (p *DBTokenProvider) connLogInitRequest(w http.ResponseWriter, r *http.Requ connType = database.ConnectionTypeWorkspaceApp } + // An empty slug_or_port is reserved for tunnel sessions (see + // coderd/workspaceagents.go logTunnelConnection); writing one + // here would collide with them in the audit session dedupe + // index. Request.Check rejects empty slugs, so this is + // unreachable today. + if slugOrPort == "" { + p.Logger.Critical(ctx, "workspace app audit session has empty slug_or_port, skipping connection log", + slog.F("workspace_id", aReq.dbReq.Workspace.ID), + slog.F("agent_id", aReq.dbReq.Agent.ID), + slog.F("app_id", aReq.dbReq.App.ID), + ) + return + } + // If we end up logging, ensure relevant fields are set. logger := p.Logger.With( slog.F("workspace_id", aReq.dbReq.Workspace.ID), diff --git a/codersdk/connectionlog.go b/codersdk/connectionlog.go index 61e1ccbb307..344c0c66927 100644 --- a/codersdk/connectionlog.go +++ b/codersdk/connectionlog.go @@ -26,6 +26,7 @@ type ConnectionLog struct { // WebInfo is only set when `type` is one of: // - `ConnectionTypePortForwarding` // - `ConnectionTypeWorkspaceApp` + // - `ConnectionTypeTunnel` WebInfo *ConnectionLogWebInfo `json:"web_info,omitempty"` // SSHInfo is only set when `type` is one of: @@ -46,6 +47,11 @@ const ( ConnectionTypeReconnectingPTY ConnectionType = "reconnecting_pty" ConnectionTypeWorkspaceApp ConnectionType = "workspace_app" ConnectionTypePortForwarding ConnectionType = "port_forwarding" + // ConnectionTypeTunnel is recorded by coderd when a client + // establishes a tailnet tunnel to a workspace agent, and carries + // the authenticated user's identity. Tunnels via the user-scoped + // tailnet API (e.g. Coder Desktop) are not currently recorded. + ConnectionTypeTunnel ConnectionType = "tunnel" ) // ConnectionLogStatus is the status of a connection log entry. diff --git a/docs/admin/monitoring/connection-logs.md b/docs/admin/monitoring/connection-logs.md index 210ca76d740..d5ace1296f4 100644 --- a/docs/admin/monitoring/connection-logs.md +++ b/docs/admin/monitoring/connection-logs.md @@ -24,6 +24,37 @@ The connection log aims to capture a record of all workspace SSH and IDE session These events are reported by workspace agents, and their receipt by the server is not guaranteed. +Agent-reported events do not identify the Coder user who connected. To +attribute SSH and IDE activity to a user, correlate them with tunnel +events for the same workspace and agent. + +## Tunnel Connections + +The connection log records a tunnel event each time a client +establishes a tunnel to a workspace agent, carrying the identity, IP +address, and user agent of the authenticated user who opened it. Tunnels +carry SSH and IDE traffic, so these events provide the user attribution +that agent-reported events lack. + +Keep the following in mind when interpreting tunnel events: + +- A tunnel event records that a tunnel was established, not what it was + used for. Any client that dials a workspace agent produces one, + including `coder ssh`, `coder port-forward`, `coder ping`, + `coder speedtest`, and IDE extensions. One tunnel may carry many + sessions, or none. +- Tunnel events are deduplicated per user, workspace agent, IP address, + and client. Clients automatically re-establish tunnels after network + interruptions or server restarts; reconnections do not produce new + events while a session is active. A new event is recorded when a + session has been idle for one hour, or when the user connects from a + new IP address or client. +- Connections made through Coder Desktop (Coder Connect) do not + currently produce tunnel events. +- Like workspace app connections, tunnel events are point-in-time + records: they have no close time and are excluded from `status:` + filter results. + ## How to Filter Connection Logs You can filter connection logs by the following parameters: @@ -36,9 +67,9 @@ You can filter connection logs by the following parameters: For more connection types, refer to the [CoderSDK documentation](https://pkg.go.dev/github.com/coder/coder/v2/codersdk#ConnectionType). - `username`: The name of the user who initiated the connection. - Results will not include SSH or IDE sessions. + Results will not include agent-reported SSH or IDE sessions. - `user_email`: The email of the user who initiated the connection. - Results will not include SSH or IDE sessions. + Results will not include agent-reported SSH or IDE sessions. - `connected_after`: The time after which the connection started. Uses the RFC3339Nano format. - `connected_before`: The time before which the connection started. diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index cae7eb143c5..a4054b18ad9 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -4195,7 +4195,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `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` | +| `web_info` | [codersdk.ConnectionLogWebInfo](#codersdkconnectionlogwebinfo) | false | | Web info is only set when `type` is one of: - `ConnectionTypePortForwarding` - `ConnectionTypeWorkspaceApp` - `ConnectionTypeTunnel` | | `workspace_id` | string | false | | | | `workspace_name` | string | false | | | | `workspace_owner_id` | string | false | | | @@ -4347,9 +4347,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in #### Enumerated Values -| Value(s) | -|--------------------------------------------------------------------------------------| -| `jetbrains`, `port_forwarding`, `reconnecting_pty`, `ssh`, `vscode`, `workspace_app` | +| Value(s) | +|------------------------------------------------------------------------------------------------| +| `jetbrains`, `port_forwarding`, `reconnecting_pty`, `ssh`, `tunnel`, `vscode`, `workspace_app` | ## codersdk.ConvertLoginRequest diff --git a/enterprise/coderd/connectionlog.go b/enterprise/coderd/connectionlog.go index eccc954ae4a..9754abab520 100644 --- a/enterprise/coderd/connectionlog.go +++ b/enterprise/coderd/connectionlog.go @@ -134,7 +134,8 @@ func convertConnectionLog(dblog database.GetConnectionLogsOffsetRow) codersdk.Co switch dblog.ConnectionLog.Type { case database.ConnectionTypeWorkspaceApp, - database.ConnectionTypePortForwarding: + database.ConnectionTypePortForwarding, + database.ConnectionTypeTunnel: webInfo = &codersdk.ConnectionLogWebInfo{ UserAgent: dblog.ConnectionLog.UserAgent.String, User: user, diff --git a/enterprise/coderd/connectionlog_test.go b/enterprise/coderd/connectionlog_test.go index fc7a0ea9029..6f8f66599e6 100644 --- a/enterprise/coderd/connectionlog_test.go +++ b/enterprise/coderd/connectionlog_test.go @@ -178,6 +178,46 @@ func TestConnectionLogs(t *testing.T) { require.Equal(t, ws.OwnerID, logs.ConnectionLogs[0].WebInfo.User.ID) }) + t.Run("WebInfoTunnel", 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() + ws := createWorkspace(t, db) + // Tunnel events are written by coderd with the connecting + // user's identity; they must surface it via WebInfo. + clog := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Time: now.Add(-time.Hour), + Type: database.ConnectionTypeTunnel, + WorkspaceID: ws.ID, + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, + UserAgent: sql.NullString{String: "coder-cli/2.0.0", Valid: true}, + UserID: uuid.NullUUID{UUID: ws.OwnerID, Valid: true}, + }) + + logs, err := client.ConnectionLogs(ctx, codersdk.ConnectionLogsRequest{}) + require.NoError(t, err) + + require.Len(t, logs.ConnectionLogs, 1) + require.EqualValues(t, 1, logs.Count) + require.Nil(t, logs.ConnectionLogs[0].SSHInfo) + require.NotNil(t, logs.ConnectionLogs[0].WebInfo) + require.Equal(t, clog.UserAgent.String, logs.ConnectionLogs[0].WebInfo.UserAgent) + require.NotNil(t, logs.ConnectionLogs[0].WebInfo.User) + require.Equal(t, ws.OwnerID, logs.ConnectionLogs[0].WebInfo.User.ID) + }) + t.Run("SSHInfo", func(t *testing.T) { t.Parallel() diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 2d3f42b2b37..c619c536829 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3740,6 +3740,7 @@ export interface ConnectionLog { * WebInfo is only set when `type` is one of: * - `ConnectionTypePortForwarding` * - `ConnectionTypeWorkspaceApp` + * - `ConnectionTypeTunnel` */ readonly web_info?: ConnectionLogWebInfo; /** @@ -3817,6 +3818,7 @@ export type ConnectionType = | "port_forwarding" | "reconnecting_pty" | "ssh" + | "tunnel" | "vscode" | "workspace_app"; @@ -3825,6 +3827,7 @@ export const ConnectionTypes: ConnectionType[] = [ "port_forwarding", "reconnecting_pty", "ssh", + "tunnel", "vscode", "workspace_app", ]; diff --git a/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.stories.tsx b/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.stories.tsx index ac28e642ea0..4df6d914767 100644 --- a/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.stories.tsx +++ b/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.stories.tsx @@ -95,6 +95,27 @@ export const JetBrains: Story = { }, }; +export const Tunnel: Story = { + args: { + connectionLog: { + ...MockWebConnectionLog, + type: "tunnel", + }, + }, +}; + +// An admin tunneling into another user's workspace, which is the +// primary audit scenario for tunnel events. +export const TunnelOtherUser: Story = { + args: { + connectionLog: { + ...MockWebConnectionLog, + type: "tunnel", + workspace_owner_username: "some-other-user", + }, + }, +}; + export const WebTerminal: Story = { args: { connectionLog: { diff --git a/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.tsx b/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.tsx index 1e85a8cd291..8676b0f9d7e 100644 --- a/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.tsx +++ b/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.tsx @@ -89,5 +89,25 @@ export const ConnectionLogDescription: FC = ({ ); } + + case "tunnel": { + if (!web_info) return null; + const { user } = web_info; + const isOwnWorkspace = workspace_owner_username === user?.username; + return ( + + {/* Tunnel rows are only written for authenticated requests, + so user should always be present. */} + {user?.username ?? "Unknown user"} established a tunnel to{" "} + {isOwnWorkspace ? "their" : `${workspace_owner_username}'s`}{" "} + + + {workspace_name} + + {" "} + workspace + + ); + } } }; diff --git a/site/src/utils/connection.ts b/site/src/utils/connection.ts index 9b13d825b21..d81f6ec71f3 100644 --- a/site/src/utils/connection.ts +++ b/site/src/utils/connection.ts @@ -14,13 +14,21 @@ export const connectionTypeToFriendlyName = (type: ConnectionType): string => { return "Port Forwarding"; case "workspace_app": return "Workspace App"; + case "tunnel": + return "Tunnel"; } }; +// connectionTypeIsWeb returns true for connection types reported by +// coderd from an HTTP request. These carry `web_info` (user, IP, user +// agent, HTTP status code) rather than agent-reported `ssh_info`, and +// are not necessarily browser connections (tunnels are typically +// established by the CLI or an IDE extension). export const connectionTypeIsWeb = (type: ConnectionType): boolean => { switch (type) { case "port_forwarding": - case "workspace_app": { + case "workspace_app": + case "tunnel": { return true; } case "reconnecting_pty":