From 7ee8432ce1b6193f3a141251fd2ea4411e9c859b Mon Sep 17 00:00:00 2001 From: Chris DiGiamo Date: Mon, 6 Jul 2026 17:00:38 +0000 Subject: [PATCH 01/11] feat(coderd): log tailnet tunnels to the connection log Agent-reported SSH/VSCode/JetBrains connection_log rows have no user_id because the agent does not know which Coder user connected (see the comment in coderd/agentapi/connectionlog.go). This makes it impossible to attribute SSH/IDE sessions to a Coder user from the connection log. Every such session is carried over a tailnet tunnel that the client opens via /api/v2/workspaceagents/{id}/coordinate using the user's API key, so coderd knows the user at that point. Add a new connection_type 'tailnet' and write one connection_log row from workspaceAgentClientCoordinate whenever an authenticated user successfully upgrades the coordinate WebSocket. The row carries user_id, ip, user_agent, workspace_id and agent_name, and is rendered via WebInfo alongside workspace_app / port_forwarding. --- coderd/apidoc/docs.go | 8 ++-- coderd/apidoc/swagger.json | 8 ++-- coderd/database/dump.sql | 7 +-- .../000539_connection_type_tailnet.down.sql | 6 +++ .../000539_connection_type_tailnet.up.sql | 5 ++ coderd/database/models.go | 9 ++-- coderd/database/queries.sql.go | 4 +- coderd/database/queries/connectionlogs.sql | 4 +- coderd/workspaceagents.go | 46 +++++++++++++++++++ coderd/workspaceagents_test.go | 44 ++++++++++++++++++ codersdk/connectionlog.go | 7 +++ docs/reference/api/schemas.md | 8 ++-- enterprise/coderd/connectionlog.go | 3 +- site/src/api/typesGenerated.ts | 2 + .../ConnectionLogDescription.stories.tsx | 9 ++++ .../ConnectionLogDescription.tsx | 20 ++++++++ site/src/utils/connection.ts | 5 +- 17 files changed, 173 insertions(+), 22 deletions(-) create mode 100644 coderd/database/migrations/000539_connection_type_tailnet.down.sql create mode 100644 coderd/database/migrations/000539_connection_type_tailnet.up.sql diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b95e78772e2..a48c451fb8f 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- ` + "`" + `ConnectionTypeTailnet` + "`" + `", "allOf": [ { "$ref": "#/definitions/codersdk.ConnectionLogWebInfo" @@ -18554,7 +18554,8 @@ const docTemplate = `{ "jetbrains", "reconnecting_pty", "workspace_app", - "port_forwarding" + "port_forwarding", + "tailnet" ], "x-enum-varnames": [ "ConnectionTypeSSH", @@ -18562,7 +18563,8 @@ const docTemplate = `{ "ConnectionTypeJetBrains", "ConnectionTypeReconnectingPTY", "ConnectionTypeWorkspaceApp", - "ConnectionTypePortForwarding" + "ConnectionTypePortForwarding", + "ConnectionTypeTailnet" ] }, "codersdk.ConvertLoginRequest": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 1a3f184e8d3..0907bfdd031 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- `ConnectionTypeTailnet`", "allOf": [ { "$ref": "#/definitions/codersdk.ConnectionLogWebInfo" @@ -16760,7 +16760,8 @@ "jetbrains", "reconnecting_pty", "workspace_app", - "port_forwarding" + "port_forwarding", + "tailnet" ], "x-enum-varnames": [ "ConnectionTypeSSH", @@ -16768,7 +16769,8 @@ "ConnectionTypeJetBrains", "ConnectionTypeReconnectingPTY", "ConnectionTypeWorkspaceApp", - "ConnectionTypePortForwarding" + "ConnectionTypePortForwarding", + "ConnectionTypeTailnet" ] }, "codersdk.ConvertLoginRequest": { diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index d99062c3671..56ecf0afbdd 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', + 'tailnet' ); CREATE TYPE cors_behavior AS ENUM ( @@ -2248,9 +2249,9 @@ CREATE TABLE connection_logs ( COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code of the web request, or the exit code of an SSH connection. For non-web connections, this is Null until we receive a disconnect event for the same connection_id.'; -COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH events. For web connections, this is the User-Agent header from the request.'; +COMMENT ON COLUMN connection_logs.user_agent IS 'Null for agent-reported (SSH) events. For HTTP-initiated connections (workspace_app, port_forwarding, tailnet), this is the User-Agent header from the request.'; -COMMENT ON COLUMN connection_logs.user_id IS 'Null for SSH events. For web connections, this is the ID of the user that made the request.'; +COMMENT ON COLUMN connection_logs.user_id IS 'Null for agent-reported (SSH) events. For HTTP-initiated connections (workspace_app, port_forwarding, tailnet), this is the ID of the user that made the request.'; COMMENT ON COLUMN connection_logs.slug_or_port IS 'Null for SSH events. For web connections, this is the slug of the app or the port number being forwarded.'; diff --git a/coderd/database/migrations/000539_connection_type_tailnet.down.sql b/coderd/database/migrations/000539_connection_type_tailnet.down.sql new file mode 100644 index 00000000000..05e7076f9f4 --- /dev/null +++ b/coderd/database/migrations/000539_connection_type_tailnet.down.sql @@ -0,0 +1,6 @@ +-- Postgres does not support removing enum values, so the down +-- migration for the `tailnet` connection_type is a no-op. + +COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH events. For web connections, this is the User-Agent header from the request.'; + +COMMENT ON COLUMN connection_logs.user_id IS 'Null for SSH events. For web connections, this is the ID of the user that made the request.'; diff --git a/coderd/database/migrations/000539_connection_type_tailnet.up.sql b/coderd/database/migrations/000539_connection_type_tailnet.up.sql new file mode 100644 index 00000000000..515c93beee9 --- /dev/null +++ b/coderd/database/migrations/000539_connection_type_tailnet.up.sql @@ -0,0 +1,5 @@ +ALTER TYPE connection_type ADD VALUE IF NOT EXISTS 'tailnet'; + +COMMENT ON COLUMN connection_logs.user_agent IS 'Null for agent-reported (SSH) events. For HTTP-initiated connections (workspace_app, port_forwarding, tailnet), this is the User-Agent header from the request.'; + +COMMENT ON COLUMN connection_logs.user_id IS 'Null for agent-reported (SSH) events. For HTTP-initiated connections (workspace_app, port_forwarding, tailnet), this is the ID of the user that made the request.'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 74e982f5fc4..7e332dfa1bb 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" + ConnectionTypeTailnet ConnectionType = "tailnet" ) func (e *ConnectionType) Scan(src interface{}) error { @@ -1899,7 +1900,8 @@ func (e ConnectionType) Valid() bool { ConnectionTypeJetbrains, ConnectionTypeReconnectingPty, ConnectionTypeWorkspaceApp, - ConnectionTypePortForwarding: + ConnectionTypePortForwarding, + ConnectionTypeTailnet: return true } return false @@ -1913,6 +1915,7 @@ func AllConnectionTypeValues() []ConnectionType { ConnectionTypeReconnectingPty, ConnectionTypeWorkspaceApp, ConnectionTypePortForwarding, + ConnectionTypeTailnet, } } @@ -5238,9 +5241,9 @@ type ConnectionLog struct { Ip pqtype.Inet `db:"ip" json:"ip"` // Either the HTTP status code of the web request, or the exit code of an SSH connection. For non-web connections, this is Null until we receive a disconnect event for the same connection_id. Code sql.NullInt32 `db:"code" json:"code"` - // Null for SSH events. For web connections, this is the User-Agent header from the request. + // Null for agent-reported (SSH) events. For HTTP-initiated connections (workspace_app, port_forwarding, tailnet), this is the User-Agent header from the request. UserAgent sql.NullString `db:"user_agent" json:"user_agent"` - // Null for SSH events. For web connections, this is the ID of the user that made the request. + // Null for agent-reported (SSH) events. For HTTP-initiated connections (workspace_app, port_forwarding, tailnet), this is the ID of the user that made the request. UserID uuid.NullUUID `db:"user_id" json:"user_id"` // Null for SSH events. For web connections, this is the slug of the app or the port number being forwarded. SlugOrPort sql.NullString `db:"slug_or_port" json:"slug_or_port"` diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ee88e6cdba8..d7a394742bb 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -13743,7 +13743,7 @@ SELECT COUNT(*) AS count FROM ( (($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') + "type" NOT IN ('workspace_app', 'port_forwarding', 'tailnet') ELSE true END -- Authorize Filter clause will be injected below in @@ -13937,7 +13937,7 @@ WHERE (($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') + "type" NOT IN ('workspace_app', 'port_forwarding', 'tailnet') 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..ef4ffccc20b 100644 --- a/coderd/database/queries/connectionlogs.sql +++ b/coderd/database/queries/connectionlogs.sql @@ -116,7 +116,7 @@ WHERE ((@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') + "type" NOT IN ('workspace_app', 'port_forwarding', 'tailnet') ELSE true END -- Authorize Filter clause will be injected below in @@ -231,7 +231,7 @@ SELECT COUNT(*) AS count FROM ( ((@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') + "type" NOT IN ('workspace_app', 'port_forwarding', 'tailnet') ELSE true END -- Authorize Filter clause will be injected below in diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 3a7fddfb61e..ee074b4634b 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -1367,6 +1367,52 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R }) return } + + // Record a connection log entry for this tunnel so that enterprise + // auditors can attribute subsequent SSH/IDE activity inside the + // workspace back to the Coder user and client that established it. + // The agent-reported SSH connection log rows do not have this + // information (see coderd/agentapi/connectionlog.go). We only log + // when the caller is an authenticated user; requests proxied by a + // workspace proxy carry no API key on this route. + if apiKey, ok := httpmw.APIKeyOptional(r); ok { + userAgent := r.UserAgent() + connLogger := *api.ConnectionLogger.Load() + err := connLogger.Upsert(ctx, database.UpsertConnectionLogParams{ + ID: uuid.New(), + Time: dbtime.Now(), + OrganizationID: waws.WorkspaceTable.OrganizationID, + WorkspaceOwnerID: waws.WorkspaceTable.OwnerID, + WorkspaceID: waws.WorkspaceTable.ID, + WorkspaceName: waws.WorkspaceTable.Name, + AgentName: waws.WorkspaceAgent.Name, + Type: database.ConnectionTypeTailnet, + 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}, + // ConnectionID is intentionally left unset so that each + // handshake produces its own row. Reusing peerID here + // would cause resume_token reconnects to upsert into the + // existing row without updating ip/user_agent. + ConnectionID: uuid.NullUUID{}, + ConnectionStatus: database.ConnectionStatusConnected, + // N/A + SlugOrPort: sql.NullString{}, + DisconnectReason: sql.NullString{}, + }) + if err != nil { + api.Logger.Error(ctx, "upsert tailnet connection log failed", + slog.F("workspace_id", waws.WorkspaceTable.ID), + slog.F("user_id", apiKey.UserID), + slog.Error(err), + ) + } + } + ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageBinary) defer wsNetConn.Close() diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index f41921c7bb5..3918384bbf0 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,49 @@ 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, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + 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() + 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.ConnectionTypeTailnet, + UserID: uuid.NullUUID{ + UUID: user.UserID, + Valid: true, + }, + }) + }, testutil.WaitShort, testutil.IntervalFast) +} + func TestWorkspaceAgentClientCoordinate_BadVersion(t *testing.T) { t.Parallel() client, db := coderdtest.NewWithDatabase(t, nil) diff --git a/codersdk/connectionlog.go b/codersdk/connectionlog.go index 61e1ccbb307..ecea0eb1e4e 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` + // - `ConnectionTypeTailnet` WebInfo *ConnectionLogWebInfo `json:"web_info,omitempty"` // SSHInfo is only set when `type` is one of: @@ -46,6 +47,12 @@ const ( ConnectionTypeReconnectingPTY ConnectionType = "reconnecting_pty" ConnectionTypeWorkspaceApp ConnectionType = "workspace_app" ConnectionTypePortForwarding ConnectionType = "port_forwarding" + // ConnectionTypeTailnet is recorded when a client establishes a + // tailnet tunnel to a workspace agent via the coordinate endpoint. + // Unlike the SSH-family types above (which are reported by the + // agent and cannot identify the connecting user), this event is + // written by coderd and carries the authenticated user's identity. + ConnectionTypeTailnet ConnectionType = "tailnet" ) // ConnectionLogStatus is the status of a connection log entry. diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index cae7eb143c5..e6274fa9c5d 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` - `ConnectionTypeTailnet` | | `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`, `tailnet`, `vscode`, `workspace_app` | ## codersdk.ConvertLoginRequest diff --git a/enterprise/coderd/connectionlog.go b/enterprise/coderd/connectionlog.go index eccc954ae4a..dbe71018d07 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.ConnectionTypeTailnet: webInfo = &codersdk.ConnectionLogWebInfo{ UserAgent: dblog.ConnectionLog.UserAgent.String, User: user, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 2d3f42b2b37..8488e2b4823 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3817,6 +3817,7 @@ export type ConnectionType = | "port_forwarding" | "reconnecting_pty" | "ssh" + | "tailnet" | "vscode" | "workspace_app"; @@ -3825,6 +3826,7 @@ export const ConnectionTypes: ConnectionType[] = [ "port_forwarding", "reconnecting_pty", "ssh", + "tailnet", "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..8d4e95c07a6 100644 --- a/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.stories.tsx +++ b/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.stories.tsx @@ -95,6 +95,15 @@ export const JetBrains: Story = { }, }; +export const Tailnet: Story = { + args: { + connectionLog: { + ...MockWebConnectionLog, + type: "tailnet", + }, + }, +}; + 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..c2c1e21b76c 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 "tailnet": { + if (!web_info) return null; + const { user } = web_info; + const isOwnWorkspace = user + ? workspace_owner_username === user.username + : false; + return ( + + {user ? user.username : "Unauthenticated user"} established a tailnet + 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..6682e21bf0f 100644 --- a/site/src/utils/connection.ts +++ b/site/src/utils/connection.ts @@ -14,13 +14,16 @@ export const connectionTypeToFriendlyName = (type: ConnectionType): string => { return "Port Forwarding"; case "workspace_app": return "Workspace App"; + case "tailnet": + return "Tailnet"; } }; export const connectionTypeIsWeb = (type: ConnectionType): boolean => { switch (type) { case "port_forwarding": - case "workspace_app": { + case "workspace_app": + case "tailnet": { return true; } case "reconnecting_pty": From b5436eb605c8e793cf3811f1531d546ec643733f Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 21 Jul 2026 20:27:59 +0000 Subject: [PATCH 02/11] feat(coderd): log tailnet tunnels to the connection log Address review feedback on the original changeset: - Rebase onto main and renumber the migration to 000551 to resolve the collision with 000539_ai_provider_icons. - Rename the connection_type value tailnet to tunnel across the migration, codersdk, enterprise converter, and frontend, since the enum value is permanent API surface and should name the event rather than the transport. - Implement a real down migration that recreates connection_type without the tunnel value, following the pattern in 000533_nats_ca_crypto_key_feature.down.sql. - Update the remaining connection_logs column comments (slug_or_port, disconnect_time, disconnect_reason) to the agent-reported vs coderd-reported taxonomy. - Extract the connection log write into api.logTunnelConnection and bound it with a 3s timeout so connection log backpressure cannot stall tunnel establishment. - Cover the status-filter exclusion with a tunnel fixture in TestConnectionLogsOffsetFilters and the WebInfo mapping with a WebInfoTunnel subtest. - Harden the coordinate test: testutil.Context, AwaitReachable assertion, Code/ConnectionStatus expectations, and a second dial asserting each handshake produces its own row. - Document tunnel connections and their semantics in docs/admin/monitoring/connection-logs.md. - Clarify the connectionTypeIsWeb helper and replace the unreachable Unauthenticated user copy in the tunnel description branch. Co-authored-by: Chris DiGiamo --- coderd/apidoc/docs.go | 6 +- coderd/apidoc/swagger.json | 6 +- coderd/database/dump.sql | 12 +-- .../000539_connection_type_tailnet.down.sql | 6 -- .../000539_connection_type_tailnet.up.sql | 5 - .../000551_connection_type_tunnel.down.sql | 31 ++++++ .../000551_connection_type_tunnel.up.sql | 11 ++ coderd/database/models.go | 16 +-- coderd/database/querier_test.go | 33 ++++-- coderd/database/queries.sql.go | 10 +- coderd/database/queries/connectionlogs.sql | 10 +- coderd/workspaceagents.go | 101 ++++++++++-------- coderd/workspaceagents_test.go | 34 +++++- codersdk/connectionlog.go | 17 +-- docs/admin/monitoring/connection-logs.md | 32 +++++- docs/reference/api/schemas.md | 8 +- enterprise/coderd/connectionlog.go | 2 +- enterprise/coderd/connectionlog_test.go | 40 +++++++ site/src/api/typesGenerated.ts | 5 +- .../ConnectionLogDescription.stories.tsx | 16 ++- .../ConnectionLogDescription.tsx | 12 +-- site/src/utils/connection.ts | 11 +- 22 files changed, 304 insertions(+), 120 deletions(-) delete mode 100644 coderd/database/migrations/000539_connection_type_tailnet.down.sql delete mode 100644 coderd/database/migrations/000539_connection_type_tailnet.up.sql create mode 100644 coderd/database/migrations/000551_connection_type_tunnel.down.sql create mode 100644 coderd/database/migrations/000551_connection_type_tunnel.up.sql diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a48c451fb8f..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` + "`" + `\n- ` + "`" + `ConnectionTypeTailnet` + "`" + `", + "description": "WebInfo is only set when ` + "`" + `type` + "`" + ` is one of:\n- ` + "`" + `ConnectionTypePortForwarding` + "`" + `\n- ` + "`" + `ConnectionTypeWorkspaceApp` + "`" + `\n- ` + "`" + `ConnectionTypeTunnel` + "`" + `", "allOf": [ { "$ref": "#/definitions/codersdk.ConnectionLogWebInfo" @@ -18555,7 +18555,7 @@ const docTemplate = `{ "reconnecting_pty", "workspace_app", "port_forwarding", - "tailnet" + "tunnel" ], "x-enum-varnames": [ "ConnectionTypeSSH", @@ -18564,7 +18564,7 @@ const docTemplate = `{ "ConnectionTypeReconnectingPTY", "ConnectionTypeWorkspaceApp", "ConnectionTypePortForwarding", - "ConnectionTypeTailnet" + "ConnectionTypeTunnel" ] }, "codersdk.ConvertLoginRequest": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 0907bfdd031..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`\n- `ConnectionTypeTailnet`", + "description": "WebInfo is only set when `type` is one of:\n- `ConnectionTypePortForwarding`\n- `ConnectionTypeWorkspaceApp`\n- `ConnectionTypeTunnel`", "allOf": [ { "$ref": "#/definitions/codersdk.ConnectionLogWebInfo" @@ -16761,7 +16761,7 @@ "reconnecting_pty", "workspace_app", "port_forwarding", - "tailnet" + "tunnel" ], "x-enum-varnames": [ "ConnectionTypeSSH", @@ -16770,7 +16770,7 @@ "ConnectionTypeReconnectingPTY", "ConnectionTypeWorkspaceApp", "ConnectionTypePortForwarding", - "ConnectionTypeTailnet" + "ConnectionTypeTunnel" ] }, "codersdk.ConvertLoginRequest": { diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 56ecf0afbdd..0c390e3a200 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -380,7 +380,7 @@ CREATE TYPE connection_type AS ENUM ( 'reconnecting_pty', 'workspace_app', 'port_forwarding', - 'tailnet' + 'tunnel' ); CREATE TYPE cors_behavior AS ENUM ( @@ -2249,17 +2249,17 @@ CREATE TABLE connection_logs ( COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code of the web request, or the exit code of an SSH connection. For non-web connections, this is Null until we receive a disconnect event for the same connection_id.'; -COMMENT ON COLUMN connection_logs.user_agent IS 'Null for agent-reported (SSH) events. For HTTP-initiated connections (workspace_app, port_forwarding, tailnet), this is the User-Agent header from the request.'; +COMMENT ON COLUMN connection_logs.user_agent IS 'Null for agent-reported (SSH) events. For coderd-reported connections (workspace_app, port_forwarding, tunnel), this is the User-Agent header from the request.'; -COMMENT ON COLUMN connection_logs.user_id IS 'Null for agent-reported (SSH) events. For HTTP-initiated connections (workspace_app, port_forwarding, tailnet), this is the ID of the user that made the request.'; +COMMENT ON COLUMN connection_logs.user_id IS 'Null for agent-reported (SSH) events. For coderd-reported connections (workspace_app, port_forwarding, tunnel), this is the ID of the user that made the request.'; -COMMENT ON COLUMN connection_logs.slug_or_port IS 'Null for SSH events. For web connections, this is the slug of the app or the port number being forwarded.'; +COMMENT ON COLUMN connection_logs.slug_or_port IS 'Null for agent-reported (SSH) events and tunnel events. For workspace_app events, this is the slug of the app. For port_forwarding events, this is the port number being forwarded.'; COMMENT ON COLUMN connection_logs.connection_id IS 'The SSH connection ID. Used to correlate connections and disconnections. As it originates from the agent, it is not guaranteed to be unique.'; -COMMENT ON COLUMN connection_logs.disconnect_time IS 'The time the connection was closed. Null for web connections. For other connections, this is null until we receive a disconnect event for the same connection_id.'; +COMMENT ON COLUMN connection_logs.disconnect_time IS 'The time the connection was closed. Null for coderd-reported connections (workspace_app, port_forwarding, tunnel). For agent-reported connections, this is null until we receive a disconnect event for the same connection_id.'; -COMMENT ON COLUMN connection_logs.disconnect_reason IS 'The reason the connection was closed. Null for web connections. For other connections, this is null until we receive a disconnect event for the same connection_id.'; +COMMENT ON COLUMN connection_logs.disconnect_reason IS 'The reason the connection was closed. Null for coderd-reported connections (workspace_app, port_forwarding, tunnel). For agent-reported connections, this is null until we receive a disconnect event for the same connection_id.'; CREATE TABLE crypto_keys ( feature crypto_key_feature NOT NULL, diff --git a/coderd/database/migrations/000539_connection_type_tailnet.down.sql b/coderd/database/migrations/000539_connection_type_tailnet.down.sql deleted file mode 100644 index 05e7076f9f4..00000000000 --- a/coderd/database/migrations/000539_connection_type_tailnet.down.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Postgres does not support removing enum values, so the down --- migration for the `tailnet` connection_type is a no-op. - -COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH events. For web connections, this is the User-Agent header from the request.'; - -COMMENT ON COLUMN connection_logs.user_id IS 'Null for SSH events. For web connections, this is the ID of the user that made the request.'; diff --git a/coderd/database/migrations/000539_connection_type_tailnet.up.sql b/coderd/database/migrations/000539_connection_type_tailnet.up.sql deleted file mode 100644 index 515c93beee9..00000000000 --- a/coderd/database/migrations/000539_connection_type_tailnet.up.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TYPE connection_type ADD VALUE IF NOT EXISTS 'tailnet'; - -COMMENT ON COLUMN connection_logs.user_agent IS 'Null for agent-reported (SSH) events. For HTTP-initiated connections (workspace_app, port_forwarding, tailnet), this is the User-Agent header from the request.'; - -COMMENT ON COLUMN connection_logs.user_id IS 'Null for agent-reported (SSH) events. For HTTP-initiated connections (workspace_app, port_forwarding, tailnet), this is the ID of the user that made the request.'; diff --git a/coderd/database/migrations/000551_connection_type_tunnel.down.sql b/coderd/database/migrations/000551_connection_type_tunnel.down.sql new file mode 100644 index 00000000000..560b119e85b --- /dev/null +++ b/coderd/database/migrations/000551_connection_type_tunnel.down.sql @@ -0,0 +1,31 @@ +-- Remove the 'tunnel' value from connection_type. Postgres cannot drop an +-- enum value in place, so recreate the type without it, following the +-- pattern in 000533_nats_ca_crypto_key_feature.down.sql. +DELETE FROM connection_logs WHERE type = 'tunnel'; + +CREATE TYPE old_connection_type AS ENUM ( + 'ssh', + 'vscode', + 'jetbrains', + 'reconnecting_pty', + 'workspace_app', + 'port_forwarding' +); + +ALTER TABLE connection_logs + ALTER COLUMN type TYPE old_connection_type + USING (type::text::old_connection_type); + +DROP TYPE connection_type; + +ALTER TYPE old_connection_type RENAME TO connection_type; + +COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH events. For web connections, this is the User-Agent header from the request.'; + +COMMENT ON COLUMN connection_logs.user_id IS 'Null for SSH events. For web connections, this is the ID of the user that made the request.'; + +COMMENT ON COLUMN connection_logs.slug_or_port IS 'Null for SSH events. For web connections, this is the slug of the app or the port number being forwarded.'; + +COMMENT ON COLUMN connection_logs.disconnect_time IS 'The time the connection was closed. Null for web connections. For other connections, this is null until we receive a disconnect event for the same connection_id.'; + +COMMENT ON COLUMN connection_logs.disconnect_reason IS 'The reason the connection was closed. Null for web connections. For other connections, this is null until we receive a disconnect event for the same connection_id.'; diff --git a/coderd/database/migrations/000551_connection_type_tunnel.up.sql b/coderd/database/migrations/000551_connection_type_tunnel.up.sql new file mode 100644 index 00000000000..e7d76156a0d --- /dev/null +++ b/coderd/database/migrations/000551_connection_type_tunnel.up.sql @@ -0,0 +1,11 @@ +ALTER TYPE connection_type ADD VALUE IF NOT EXISTS 'tunnel'; + +COMMENT ON COLUMN connection_logs.user_agent IS 'Null for agent-reported (SSH) events. For coderd-reported connections (workspace_app, port_forwarding, tunnel), this is the User-Agent header from the request.'; + +COMMENT ON COLUMN connection_logs.user_id IS 'Null for agent-reported (SSH) events. For coderd-reported connections (workspace_app, port_forwarding, tunnel), this is the ID of the user that made the request.'; + +COMMENT ON COLUMN connection_logs.slug_or_port IS 'Null for agent-reported (SSH) events and tunnel events. For workspace_app events, this is the slug of the app. For port_forwarding events, this is the port number being forwarded.'; + +COMMENT ON COLUMN connection_logs.disconnect_time IS 'The time the connection was closed. Null for coderd-reported connections (workspace_app, port_forwarding, tunnel). For agent-reported connections, this is null until we receive a disconnect event for the same connection_id.'; + +COMMENT ON COLUMN connection_logs.disconnect_reason IS 'The reason the connection was closed. Null for coderd-reported connections (workspace_app, port_forwarding, tunnel). For agent-reported connections, this is null until we receive a disconnect event for the same connection_id.'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 7e332dfa1bb..b3132ca25d0 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1855,7 +1855,7 @@ const ( ConnectionTypeReconnectingPty ConnectionType = "reconnecting_pty" ConnectionTypeWorkspaceApp ConnectionType = "workspace_app" ConnectionTypePortForwarding ConnectionType = "port_forwarding" - ConnectionTypeTailnet ConnectionType = "tailnet" + ConnectionTypeTunnel ConnectionType = "tunnel" ) func (e *ConnectionType) Scan(src interface{}) error { @@ -1901,7 +1901,7 @@ func (e ConnectionType) Valid() bool { ConnectionTypeReconnectingPty, ConnectionTypeWorkspaceApp, ConnectionTypePortForwarding, - ConnectionTypeTailnet: + ConnectionTypeTunnel: return true } return false @@ -1915,7 +1915,7 @@ func AllConnectionTypeValues() []ConnectionType { ConnectionTypeReconnectingPty, ConnectionTypeWorkspaceApp, ConnectionTypePortForwarding, - ConnectionTypeTailnet, + ConnectionTypeTunnel, } } @@ -5241,17 +5241,17 @@ type ConnectionLog struct { Ip pqtype.Inet `db:"ip" json:"ip"` // Either the HTTP status code of the web request, or the exit code of an SSH connection. For non-web connections, this is Null until we receive a disconnect event for the same connection_id. Code sql.NullInt32 `db:"code" json:"code"` - // Null for agent-reported (SSH) events. For HTTP-initiated connections (workspace_app, port_forwarding, tailnet), this is the User-Agent header from the request. + // Null for agent-reported (SSH) events. For coderd-reported connections (workspace_app, port_forwarding, tunnel), this is the User-Agent header from the request. UserAgent sql.NullString `db:"user_agent" json:"user_agent"` - // Null for agent-reported (SSH) events. For HTTP-initiated connections (workspace_app, port_forwarding, tailnet), this is the ID of the user that made the request. + // Null for agent-reported (SSH) events. For coderd-reported connections (workspace_app, port_forwarding, tunnel), this is the ID of the user that made the request. UserID uuid.NullUUID `db:"user_id" json:"user_id"` - // Null for SSH events. For web connections, this is the slug of the app or the port number being forwarded. + // Null for agent-reported (SSH) events and tunnel events. For workspace_app events, this is the slug of the app. For port_forwarding events, this is the port number being forwarded. SlugOrPort sql.NullString `db:"slug_or_port" json:"slug_or_port"` // The SSH connection ID. Used to correlate connections and disconnections. As it originates from the agent, it is not guaranteed to be unique. ConnectionID uuid.NullUUID `db:"connection_id" json:"connection_id"` - // The time the connection was closed. Null for web connections. For other connections, this is null until we receive a disconnect event for the same connection_id. + // The time the connection was closed. Null for coderd-reported connections (workspace_app, port_forwarding, tunnel). For agent-reported connections, this is null until we receive a disconnect event for the same connection_id. DisconnectTime sql.NullTime `db:"disconnect_time" json:"disconnect_time"` - // The reason the connection was closed. Null for web connections. For other connections, this is null until we receive a disconnect event for the same connection_id. + // The reason the connection was closed. Null for coderd-reported connections (workspace_app, port_forwarding, tunnel). For agent-reported connections, this is null until we receive a disconnect event for the same connection_id. DisconnectReason sql.NullString `db:"disconnect_reason" json:"disconnect_reason"` } 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 d7a394742bb..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', 'tailnet') + -- 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', 'tailnet') + -- 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 ef4ffccc20b..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', 'tailnet') + -- 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', 'tailnet') + -- 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 ee074b4634b..875c8dbc64f 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -1368,50 +1368,7 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R return } - // Record a connection log entry for this tunnel so that enterprise - // auditors can attribute subsequent SSH/IDE activity inside the - // workspace back to the Coder user and client that established it. - // The agent-reported SSH connection log rows do not have this - // information (see coderd/agentapi/connectionlog.go). We only log - // when the caller is an authenticated user; requests proxied by a - // workspace proxy carry no API key on this route. - if apiKey, ok := httpmw.APIKeyOptional(r); ok { - userAgent := r.UserAgent() - connLogger := *api.ConnectionLogger.Load() - err := connLogger.Upsert(ctx, database.UpsertConnectionLogParams{ - ID: uuid.New(), - Time: dbtime.Now(), - OrganizationID: waws.WorkspaceTable.OrganizationID, - WorkspaceOwnerID: waws.WorkspaceTable.OwnerID, - WorkspaceID: waws.WorkspaceTable.ID, - WorkspaceName: waws.WorkspaceTable.Name, - AgentName: waws.WorkspaceAgent.Name, - Type: database.ConnectionTypeTailnet, - 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}, - // ConnectionID is intentionally left unset so that each - // handshake produces its own row. Reusing peerID here - // would cause resume_token reconnects to upsert into the - // existing row without updating ip/user_agent. - ConnectionID: uuid.NullUUID{}, - ConnectionStatus: database.ConnectionStatusConnected, - // N/A - SlugOrPort: sql.NullString{}, - DisconnectReason: sql.NullString{}, - }) - if err != nil { - api.Logger.Error(ctx, "upsert tailnet connection log failed", - slog.F("workspace_id", waws.WorkspaceTable.ID), - slog.F("user_id", apiKey.UserID), - slog.Error(err), - ) - } - } + api.logTunnelConnection(ctx, r, waws) ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageBinary) defer wsNetConn.Close() @@ -1432,6 +1389,62 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R } } +// logTunnelConnection records a connection log entry for a tunnel to a +// workspace agent so that enterprise auditors can attribute subsequent +// SSH/IDE activity inside the workspace back to the Coder user and +// client that established it. The agent-reported SSH connection log +// rows do not have this information (see +// coderd/agentapi/connectionlog.go). We only log when the caller is an +// authenticated user; requests proxied by a workspace proxy carry no +// API key on this route. +func (api *API) logTunnelConnection(ctx context.Context, r *http.Request, waws database.GetWorkspaceAgentAndWorkspaceByIDRow) { + apiKey, ok := httpmw.APIKeyOptional(r) + if !ok { + return + } + // Bound the write so that connection log backpressure (e.g. a + // wedged database write) cannot stall tunnel establishment. Losing + // a log row under extreme backpressure is preferable to refusing + // tunnels; the error below records the loss. + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + userAgent := r.UserAgent() + connLogger := *api.ConnectionLogger.Load() + err := connLogger.Upsert(ctx, database.UpsertConnectionLogParams{ + ID: uuid.New(), + Time: dbtime.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}, + // ConnectionID is intentionally left unset so that each + // handshake produces its own row. Reusing peerID here + // would cause resume_token reconnects to upsert into the + // existing row without updating ip/user_agent. + 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 failed", + 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 3918384bbf0..70af76a09b3 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -936,8 +936,7 @@ func TestWorkspaceAgentClientCoordinate_ConnectionLog(t *testing.T) { _ = agenttest.New(t, client.URL, r.AgentToken) resources := coderdtest.AwaitWorkspaceAgents(t, client, r.Workspace.ID) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) conn, err := workspacesdk.New(client). DialAgent(ctx, resources[0].Agents[0].ID, &workspacesdk.DialAgentOptions{ @@ -945,7 +944,7 @@ func TestWorkspaceAgentClientCoordinate_ConnectionLog(t *testing.T) { }) require.NoError(t, err) defer conn.Close() - conn.AwaitReachable(ctx) + require.True(t, conn.AwaitReachable(ctx)) require.Eventually(t, func() bool { return connLogger.Contains(t, database.UpsertConnectionLogParams{ @@ -954,13 +953,40 @@ func TestWorkspaceAgentClientCoordinate_ConnectionLog(t *testing.T) { WorkspaceID: r.Workspace.ID, WorkspaceName: r.Workspace.Name, AgentName: resources[0].Agents[0].Name, - Type: database.ConnectionTypeTailnet, + 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 must produce its own row rather than + // upserting into the first one. + 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() + require.True(t, conn2.AwaitReachable(ctx)) + + require.Eventually(t, func() bool { + ids := make(map[uuid.UUID]struct{}) + for _, cl := range connLogger.ConnectionLogs() { + if cl.Type == database.ConnectionTypeTunnel { + ids[cl.ID] = struct{}{} + } + } + return len(ids) >= 2 + }, testutil.WaitShort, testutil.IntervalFast) } func TestWorkspaceAgentClientCoordinate_BadVersion(t *testing.T) { diff --git a/codersdk/connectionlog.go b/codersdk/connectionlog.go index ecea0eb1e4e..617b735a411 100644 --- a/codersdk/connectionlog.go +++ b/codersdk/connectionlog.go @@ -26,7 +26,7 @@ type ConnectionLog struct { // WebInfo is only set when `type` is one of: // - `ConnectionTypePortForwarding` // - `ConnectionTypeWorkspaceApp` - // - `ConnectionTypeTailnet` + // - `ConnectionTypeTunnel` WebInfo *ConnectionLogWebInfo `json:"web_info,omitempty"` // SSHInfo is only set when `type` is one of: @@ -47,12 +47,15 @@ const ( ConnectionTypeReconnectingPTY ConnectionType = "reconnecting_pty" ConnectionTypeWorkspaceApp ConnectionType = "workspace_app" ConnectionTypePortForwarding ConnectionType = "port_forwarding" - // ConnectionTypeTailnet is recorded when a client establishes a - // tailnet tunnel to a workspace agent via the coordinate endpoint. - // Unlike the SSH-family types above (which are reported by the - // agent and cannot identify the connecting user), this event is - // written by coderd and carries the authenticated user's identity. - ConnectionTypeTailnet ConnectionType = "tailnet" + // ConnectionTypeTunnel is recorded when a client establishes a + // tailnet tunnel to a workspace agent via the workspace agent + // coordinate endpoint. Unlike the agent-reported types (ssh, + // vscode, jetbrains, reconnecting_pty), which cannot identify the + // connecting user, this event is written by coderd and carries the + // authenticated user's identity. Tunnels established through 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..5cac34382a2 100644 --- a/docs/admin/monitoring/connection-logs.md +++ b/docs/admin/monitoring/connection-logs.md @@ -24,6 +24,34 @@ 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. +- Clients automatically re-establish tunnels after network + interruptions or server restarts, and each reconnection produces a + new event. +- 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 +64,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 e6274fa9c5d..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` - `ConnectionTypeTailnet` | +| `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`, `tailnet`, `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 dbe71018d07..9754abab520 100644 --- a/enterprise/coderd/connectionlog.go +++ b/enterprise/coderd/connectionlog.go @@ -135,7 +135,7 @@ func convertConnectionLog(dblog database.GetConnectionLogsOffsetRow) codersdk.Co switch dblog.ConnectionLog.Type { case database.ConnectionTypeWorkspaceApp, database.ConnectionTypePortForwarding, - database.ConnectionTypeTailnet: + 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 8488e2b4823..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,7 +3818,7 @@ export type ConnectionType = | "port_forwarding" | "reconnecting_pty" | "ssh" - | "tailnet" + | "tunnel" | "vscode" | "workspace_app"; @@ -3826,7 +3827,7 @@ export const ConnectionTypes: ConnectionType[] = [ "port_forwarding", "reconnecting_pty", "ssh", - "tailnet", + "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 8d4e95c07a6..4df6d914767 100644 --- a/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.stories.tsx +++ b/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.stories.tsx @@ -95,11 +95,23 @@ export const JetBrains: Story = { }, }; -export const Tailnet: Story = { +export const Tunnel: Story = { args: { connectionLog: { ...MockWebConnectionLog, - type: "tailnet", + 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", }, }, }; diff --git a/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.tsx b/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.tsx index c2c1e21b76c..8676b0f9d7e 100644 --- a/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.tsx +++ b/site/src/pages/ConnectionLogPage/ConnectionLogRow/ConnectionLogDescription/ConnectionLogDescription.tsx @@ -90,16 +90,16 @@ export const ConnectionLogDescription: FC = ({ ); } - case "tailnet": { + case "tunnel": { if (!web_info) return null; const { user } = web_info; - const isOwnWorkspace = user - ? workspace_owner_username === user.username - : false; + const isOwnWorkspace = workspace_owner_username === user?.username; return ( - {user ? user.username : "Unauthenticated user"} established a tailnet - tunnel to {isOwnWorkspace ? "their" : `${workspace_owner_username}'s`}{" "} + {/* 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} diff --git a/site/src/utils/connection.ts b/site/src/utils/connection.ts index 6682e21bf0f..d81f6ec71f3 100644 --- a/site/src/utils/connection.ts +++ b/site/src/utils/connection.ts @@ -14,16 +14,21 @@ export const connectionTypeToFriendlyName = (type: ConnectionType): string => { return "Port Forwarding"; case "workspace_app": return "Workspace App"; - case "tailnet": - return "Tailnet"; + 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 "tailnet": { + case "tunnel": { return true; } case "reconnecting_pty": From ff1fd2b57e84779c99453229353527be44c7d1a8 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 21 Jul 2026 21:00:56 +0000 Subject: [PATCH 03/11] feat(coderd): deduplicate tunnel connection log events Reuse the workspace_app_audit_sessions mechanism to collapse tunnel reconnections into one connection log row per active session. Clients automatically re-dial the coordinate endpoint after network blips, load balancer timeouts, and coderd restarts, so logging every handshake would flood the connection log with reconnect noise that is indistinguishable from genuine user activity. Tunnel sessions are keyed on (agent, user, IP, user agent) with app_id = uuid.Nil and an empty slug, matching how port forwarding uses the table; status code 101 keeps them from colliding with app sessions. A new row is logged only when no session exists for the key or the session has been idle past the stale interval (1 hour by default, sliding). On dedup failure the log write is skipped, matching the workspace app precedent of not spamming the connection log during database problems. --- coderd/database/dump.sql | 2 +- .../000551_connection_type_tunnel.down.sql | 2 + .../000551_connection_type_tunnel.up.sql | 2 + coderd/database/models.go | 2 +- coderd/workspaceagents.go | 63 ++++++++++++++++--- coderd/workspaceagents_test.go | 22 ++++--- docs/admin/monitoring/connection-logs.md | 9 ++- 7 files changed, 80 insertions(+), 22 deletions(-) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 0c390e3a200..4d86889c512 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -3864,7 +3864,7 @@ CREATE UNLOGGED TABLE workspace_app_audit_sessions ( id uuid NOT NULL ); -COMMENT ON TABLE workspace_app_audit_sessions IS 'Audit sessions for workspace apps, the data in this table is ephemeral and is used to deduplicate audit log entries for workspace apps. While a session is active, the same data will not be logged again. This table does not store historical data.'; +COMMENT ON TABLE workspace_app_audit_sessions IS 'Audit sessions for workspace apps, ports, and tunnels. The data in this table is ephemeral and is used to deduplicate connection log entries. While a session is active, the same data will not be logged again. This table does not store historical data.'; COMMENT ON COLUMN workspace_app_audit_sessions.agent_id IS 'The agent that the workspace app or port forward belongs to.'; diff --git a/coderd/database/migrations/000551_connection_type_tunnel.down.sql b/coderd/database/migrations/000551_connection_type_tunnel.down.sql index 560b119e85b..4cd35c60b22 100644 --- a/coderd/database/migrations/000551_connection_type_tunnel.down.sql +++ b/coderd/database/migrations/000551_connection_type_tunnel.down.sql @@ -29,3 +29,5 @@ COMMENT ON COLUMN connection_logs.slug_or_port IS 'Null for SSH events. For web COMMENT ON COLUMN connection_logs.disconnect_time IS 'The time the connection was closed. Null for web connections. For other connections, this is null until we receive a disconnect event for the same connection_id.'; COMMENT ON COLUMN connection_logs.disconnect_reason IS 'The reason the connection was closed. Null for web connections. For other connections, this is null until we receive a disconnect event for the same connection_id.'; + +COMMENT ON TABLE workspace_app_audit_sessions IS 'Audit sessions for workspace apps, the data in this table is ephemeral and is used to deduplicate audit log entries for workspace apps. While a session is active, the same data will not be logged again. This table does not store historical data.'; diff --git a/coderd/database/migrations/000551_connection_type_tunnel.up.sql b/coderd/database/migrations/000551_connection_type_tunnel.up.sql index e7d76156a0d..4afaf5f49fb 100644 --- a/coderd/database/migrations/000551_connection_type_tunnel.up.sql +++ b/coderd/database/migrations/000551_connection_type_tunnel.up.sql @@ -9,3 +9,5 @@ COMMENT ON COLUMN connection_logs.slug_or_port IS 'Null for agent-reported (SSH) COMMENT ON COLUMN connection_logs.disconnect_time IS 'The time the connection was closed. Null for coderd-reported connections (workspace_app, port_forwarding, tunnel). For agent-reported connections, this is null until we receive a disconnect event for the same connection_id.'; COMMENT ON COLUMN connection_logs.disconnect_reason IS 'The reason the connection was closed. Null for coderd-reported connections (workspace_app, port_forwarding, tunnel). For agent-reported connections, this is null until we receive a disconnect event for the same connection_id.'; + +COMMENT ON TABLE workspace_app_audit_sessions IS 'Audit sessions for workspace apps, ports, and tunnels. The data in this table is ephemeral and is used to deduplicate connection log entries. While a session is active, the same data will not be logged again. This table does not store historical data.'; diff --git a/coderd/database/models.go b/coderd/database/models.go index b3132ca25d0..aead09d6de6 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -6555,7 +6555,7 @@ type WorkspaceApp struct { Tooltip string `db:"tooltip" json:"tooltip"` } -// Audit sessions for workspace apps, the data in this table is ephemeral and is used to deduplicate audit log entries for workspace apps. While a session is active, the same data will not be logged again. This table does not store historical data. +// Audit sessions for workspace apps, ports, and tunnels. The data in this table is ephemeral and is used to deduplicate connection log entries. While a session is active, the same data will not be logged again. This table does not store historical data. type WorkspaceAppAuditSession struct { // The agent that the workspace app or port forward belongs to. AgentID uuid.UUID `db:"agent_id" json:"agent_id"` diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 875c8dbc64f..8a3f4fc850c 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -1402,17 +1402,66 @@ func (api *API) logTunnelConnection(ctx context.Context, r *http.Request, waws d if !ok { return } - // Bound the write so that connection log backpressure (e.g. a + // Bound the writes so that connection log backpressure (e.g. a // wedged database write) cannot stall tunnel establishment. Losing // a log row under extreme backpressure is preferable to refusing - // tunnels; the error below records the loss. + // tunnels; the errors below record the loss. ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() userAgent := r.UserAgent() + now := dbtime.Now() + + // Clients automatically re-dial the coordinate endpoint after + // network blips, load balancer timeouts, and coderd restarts, so a + // row per handshake would flood the connection log with reconnect + // noise. Deduplicate through the same audit session mechanism used + // by workspace apps: only log when there is no active session for + // this (agent, user, IP, client) key. Tunnel sessions use uuid.Nil + // for app_id and an empty slug like port forwarding does; status + // code 101 keeps them from colliding with app sessions. + staleInterval := api.Options.WorkspaceAppAuditSessionTimeout + if staleInterval == 0 { + staleInterval = time.Hour + } + // nolint:gocritic // System context is needed to write audit sessions. + dangerousSystemCtx := dbauthz.AsSystemRestricted(ctx) + newOrStale, err := api.Database.UpsertWorkspaceAppAuditSession(dangerousSystemCtx, 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 { + api.Logger.Error(ctx, "upsert tunnel audit session failed", + slog.F("workspace_id", waws.WorkspaceTable.ID), + slog.F("user_id", apiKey.UserID), + slog.Error(err), + ) + // Avoid spamming the connection log if deduplication failed; + // this should only happen if there are problems communicating + // with the database. + return + } + if !newOrStale { + // An active session for this key was already logged; this + // handshake is a reconnection. + return + } + connLogger := *api.ConnectionLogger.Load() - err := connLogger.Upsert(ctx, database.UpsertConnectionLogParams{ + err = connLogger.Upsert(ctx, database.UpsertConnectionLogParams{ ID: uuid.New(), - Time: dbtime.Now(), + Time: now, OrganizationID: waws.WorkspaceTable.OrganizationID, WorkspaceOwnerID: waws.WorkspaceTable.OwnerID, WorkspaceID: waws.WorkspaceTable.ID, @@ -1427,9 +1476,9 @@ func (api *API) logTunnelConnection(ctx context.Context, r *http.Request, waws d UserAgent: sql.NullString{String: userAgent, Valid: userAgent != ""}, UserID: uuid.NullUUID{UUID: apiKey.UserID, Valid: true}, // ConnectionID is intentionally left unset so that each - // handshake produces its own row. Reusing peerID here - // would cause resume_token reconnects to upsert into the - // existing row without updating ip/user_agent. + // new or stale audit session produces its own row. Reusing + // peerID here would cause resume_token reconnects to upsert + // into the existing row without updating ip/user_agent. ConnectionID: uuid.NullUUID{}, ConnectionStatus: database.ConnectionStatusConnected, // N/A diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 70af76a09b3..cfc4845d516 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -968,25 +968,27 @@ func TestWorkspaceAgentClientCoordinate_ConnectionLog(t *testing.T) { err = conn.Close() require.NoError(t, err) - // A second handshake must produce its own row rather than - // upserting into the first one. + // 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)) - require.Eventually(t, func() bool { - ids := make(map[uuid.UUID]struct{}) - for _, cl := range connLogger.ConnectionLogs() { - if cl.Type == database.ConnectionTypeTunnel { - ids[cl.ID] = struct{}{} - } + tunnelRows := 0 + for _, cl := range connLogger.ConnectionLogs() { + if cl.Type == database.ConnectionTypeTunnel { + tunnelRows++ } - return len(ids) >= 2 - }, testutil.WaitShort, testutil.IntervalFast) + } + require.Equal(t, 1, tunnelRows) } func TestWorkspaceAgentClientCoordinate_BadVersion(t *testing.T) { diff --git a/docs/admin/monitoring/connection-logs.md b/docs/admin/monitoring/connection-logs.md index 5cac34382a2..d5ace1296f4 100644 --- a/docs/admin/monitoring/connection-logs.md +++ b/docs/admin/monitoring/connection-logs.md @@ -43,9 +43,12 @@ Keep the following in mind when interpreting tunnel events: including `coder ssh`, `coder port-forward`, `coder ping`, `coder speedtest`, and IDE extensions. One tunnel may carry many sessions, or none. -- Clients automatically re-establish tunnels after network - interruptions or server restarts, and each reconnection produces a - new event. +- 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 From 91799f62b567d01d61d4965340b6de29fe06ffa5 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 21 Jul 2026 21:10:53 +0000 Subject: [PATCH 04/11] fix(coderd/database): make tunnel enum down migration a no-op Removing an enum value requires 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 because they cannot exist in the old type. Leaving the value in place is harmless and matches the precedent of other enum-value additions. --- .../000551_connection_type_tunnel.down.sql | 29 +++++-------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/coderd/database/migrations/000551_connection_type_tunnel.down.sql b/coderd/database/migrations/000551_connection_type_tunnel.down.sql index 4cd35c60b22..e0e28ba228a 100644 --- a/coderd/database/migrations/000551_connection_type_tunnel.down.sql +++ b/coderd/database/migrations/000551_connection_type_tunnel.down.sql @@ -1,24 +1,11 @@ --- Remove the 'tunnel' value from connection_type. Postgres cannot drop an --- enum value in place, so recreate the type without it, following the --- pattern in 000533_nats_ca_crypto_key_feature.down.sql. -DELETE FROM connection_logs WHERE type = 'tunnel'; - -CREATE TYPE old_connection_type AS ENUM ( - 'ssh', - 'vscode', - 'jetbrains', - 'reconnecting_pty', - 'workspace_app', - 'port_forwarding' -); - -ALTER TABLE connection_logs - ALTER COLUMN type TYPE old_connection_type - USING (type::text::old_connection_type); - -DROP TYPE connection_type; - -ALTER TYPE old_connection_type RENAME TO connection_type; +-- 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). COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH events. For web connections, this is the User-Agent header from the request.'; From 8c274cb4d503b6196a5fc83f642d48ff37df493d Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 21 Jul 2026 21:19:14 +0000 Subject: [PATCH 05/11] chore(coderd): clarify tunnel dedup comment and error log messages Explain how the status code keeps tunnel audit sessions from colliding with app and port forwarding sessions: the status code is part of the session unique key, and app sessions record the token authorization status (200, 4xx) while tunnel sessions always record 101, so the keys can never conflict. Drop the redundant 'failed' suffix from the error log messages; the Error level already implies failure. --- coderd/workspaceagents.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 8a3f4fc850c..038b2245420 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -1417,8 +1417,13 @@ func (api *API) logTunnelConnection(ctx context.Context, r *http.Request, waws d // noise. Deduplicate through the same audit session mechanism used // by workspace apps: only log when there is no active session for // this (agent, user, IP, client) key. Tunnel sessions use uuid.Nil - // for app_id and an empty slug like port forwarding does; status - // code 101 keeps them from colliding with app sessions. + // for app_id and an empty slug like port forwarding does. The + // status code is part of the session unique key: app and port + // forwarding sessions record the HTTP status of the token + // authorization (200, 4xx, ...), never 101, while tunnel sessions + // always record 101 (the WebSocket upgrade), so a tunnel session + // can never upsert into an app session row even when the agent, + // user, IP, and user agent all match. staleInterval := api.Options.WorkspaceAppAuditSessionTimeout if staleInterval == 0 { staleInterval = time.Hour @@ -1442,7 +1447,7 @@ func (api *API) logTunnelConnection(ctx context.Context, r *http.Request, waws d UpdatedAt: now, }) if err != nil { - api.Logger.Error(ctx, "upsert tunnel audit session failed", + api.Logger.Error(ctx, "upsert tunnel audit session", slog.F("workspace_id", waws.WorkspaceTable.ID), slog.F("user_id", apiKey.UserID), slog.Error(err), @@ -1486,7 +1491,7 @@ func (api *API) logTunnelConnection(ctx context.Context, r *http.Request, waws d DisconnectReason: sql.NullString{}, }) if err != nil { - api.Logger.Error(ctx, "upsert tunnel connection log failed", + api.Logger.Error(ctx, "upsert tunnel connection log", slog.F("workspace_id", waws.WorkspaceTable.ID), slog.F("user_id", apiKey.UserID), slog.Error(err), From 19b6bb21a2dbfe4e01049c8117c31bc500c2d3b0 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 28 Jul 2026 15:39:58 +0000 Subject: [PATCH 06/11] fix(coderd): renumber tunnel migration and isolate connection log deadline Renumber the tunnel enum migration from 000551 to 000556 after rebasing onto main, which gained migrations 000551 through 000555. Give the connection log enqueue its own 3s deadline detached from the audit session upsert's budget. Sharing one deadline meant a slow session upsert could exhaust it before the enqueue ran, committing the session row with no log row written; the active session would then suppress retries for the entire stale interval, dropping the tunnel from the audit log for up to an hour after the database recovered. --- ...down.sql => 000556_connection_type_tunnel.down.sql} | 0 ...nel.up.sql => 000556_connection_type_tunnel.up.sql} | 0 coderd/workspaceagents.go | 10 +++++++++- 3 files changed, 9 insertions(+), 1 deletion(-) rename coderd/database/migrations/{000551_connection_type_tunnel.down.sql => 000556_connection_type_tunnel.down.sql} (100%) rename coderd/database/migrations/{000551_connection_type_tunnel.up.sql => 000556_connection_type_tunnel.up.sql} (100%) diff --git a/coderd/database/migrations/000551_connection_type_tunnel.down.sql b/coderd/database/migrations/000556_connection_type_tunnel.down.sql similarity index 100% rename from coderd/database/migrations/000551_connection_type_tunnel.down.sql rename to coderd/database/migrations/000556_connection_type_tunnel.down.sql diff --git a/coderd/database/migrations/000551_connection_type_tunnel.up.sql b/coderd/database/migrations/000556_connection_type_tunnel.up.sql similarity index 100% rename from coderd/database/migrations/000551_connection_type_tunnel.up.sql rename to coderd/database/migrations/000556_connection_type_tunnel.up.sql diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 038b2245420..04f4c46a0fc 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -1408,6 +1408,14 @@ func (api *API) logTunnelConnection(ctx context.Context, r *http.Request, waws d // tunnels; the errors below record the loss. ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() + // The connection log enqueue gets its own budget so that a slow + // audit session upsert cannot exhaust the deadline before the + // enqueue runs. Without this, the session row would be committed + // with no log row written, and the active session would suppress + // retries for the entire stale interval. WithoutCancel keeps the + // enqueue alive if the client disconnects mid-handshake. + logCtx, logCancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second) + defer logCancel() userAgent := r.UserAgent() now := dbtime.Now() @@ -1464,7 +1472,7 @@ func (api *API) logTunnelConnection(ctx context.Context, r *http.Request, waws d } connLogger := *api.ConnectionLogger.Load() - err = connLogger.Upsert(ctx, database.UpsertConnectionLogParams{ + err = connLogger.Upsert(logCtx, database.UpsertConnectionLogParams{ ID: uuid.New(), Time: now, OrganizationID: waws.WorkspaceTable.OrganizationID, From de58458338049bdf51decbe41ced1b890da5f41e Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 28 Jul 2026 17:09:11 +0000 Subject: [PATCH 07/11] chore(coderd/database): drop column comment updates from tunnel migration Keep the migration to the enum addition only. The prior comment rewording was cosmetic and added churn to both the up and down migrations. --- coderd/database/dump.sql | 12 ++++++------ .../000556_connection_type_tunnel.down.sql | 12 ------------ .../migrations/000556_connection_type_tunnel.up.sql | 12 ------------ coderd/database/models.go | 12 ++++++------ 4 files changed, 12 insertions(+), 36 deletions(-) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 4d86889c512..545cba9d220 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -2249,17 +2249,17 @@ CREATE TABLE connection_logs ( COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code of the web request, or the exit code of an SSH connection. For non-web connections, this is Null until we receive a disconnect event for the same connection_id.'; -COMMENT ON COLUMN connection_logs.user_agent IS 'Null for agent-reported (SSH) events. For coderd-reported connections (workspace_app, port_forwarding, tunnel), this is the User-Agent header from the request.'; +COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH events. For web connections, this is the User-Agent header from the request.'; -COMMENT ON COLUMN connection_logs.user_id IS 'Null for agent-reported (SSH) events. For coderd-reported connections (workspace_app, port_forwarding, tunnel), this is the ID of the user that made the request.'; +COMMENT ON COLUMN connection_logs.user_id IS 'Null for SSH events. For web connections, this is the ID of the user that made the request.'; -COMMENT ON COLUMN connection_logs.slug_or_port IS 'Null for agent-reported (SSH) events and tunnel events. For workspace_app events, this is the slug of the app. For port_forwarding events, this is the port number being forwarded.'; +COMMENT ON COLUMN connection_logs.slug_or_port IS 'Null for SSH events. For web connections, this is the slug of the app or the port number being forwarded.'; COMMENT ON COLUMN connection_logs.connection_id IS 'The SSH connection ID. Used to correlate connections and disconnections. As it originates from the agent, it is not guaranteed to be unique.'; -COMMENT ON COLUMN connection_logs.disconnect_time IS 'The time the connection was closed. Null for coderd-reported connections (workspace_app, port_forwarding, tunnel). For agent-reported connections, this is null until we receive a disconnect event for the same connection_id.'; +COMMENT ON COLUMN connection_logs.disconnect_time IS 'The time the connection was closed. Null for web connections. For other connections, this is null until we receive a disconnect event for the same connection_id.'; -COMMENT ON COLUMN connection_logs.disconnect_reason IS 'The reason the connection was closed. Null for coderd-reported connections (workspace_app, port_forwarding, tunnel). For agent-reported connections, this is null until we receive a disconnect event for the same connection_id.'; +COMMENT ON COLUMN connection_logs.disconnect_reason IS 'The reason the connection was closed. Null for web connections. For other connections, this is null until we receive a disconnect event for the same connection_id.'; CREATE TABLE crypto_keys ( feature crypto_key_feature NOT NULL, @@ -3864,7 +3864,7 @@ CREATE UNLOGGED TABLE workspace_app_audit_sessions ( id uuid NOT NULL ); -COMMENT ON TABLE workspace_app_audit_sessions IS 'Audit sessions for workspace apps, ports, and tunnels. The data in this table is ephemeral and is used to deduplicate connection log entries. While a session is active, the same data will not be logged again. This table does not store historical data.'; +COMMENT ON TABLE workspace_app_audit_sessions IS 'Audit sessions for workspace apps, the data in this table is ephemeral and is used to deduplicate audit log entries for workspace apps. While a session is active, the same data will not be logged again. This table does not store historical data.'; COMMENT ON COLUMN workspace_app_audit_sessions.agent_id IS 'The agent that the workspace app or port forward belongs to.'; diff --git a/coderd/database/migrations/000556_connection_type_tunnel.down.sql b/coderd/database/migrations/000556_connection_type_tunnel.down.sql index e0e28ba228a..217078407e0 100644 --- a/coderd/database/migrations/000556_connection_type_tunnel.down.sql +++ b/coderd/database/migrations/000556_connection_type_tunnel.down.sql @@ -6,15 +6,3 @@ -- 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). - -COMMENT ON COLUMN connection_logs.user_agent IS 'Null for SSH events. For web connections, this is the User-Agent header from the request.'; - -COMMENT ON COLUMN connection_logs.user_id IS 'Null for SSH events. For web connections, this is the ID of the user that made the request.'; - -COMMENT ON COLUMN connection_logs.slug_or_port IS 'Null for SSH events. For web connections, this is the slug of the app or the port number being forwarded.'; - -COMMENT ON COLUMN connection_logs.disconnect_time IS 'The time the connection was closed. Null for web connections. For other connections, this is null until we receive a disconnect event for the same connection_id.'; - -COMMENT ON COLUMN connection_logs.disconnect_reason IS 'The reason the connection was closed. Null for web connections. For other connections, this is null until we receive a disconnect event for the same connection_id.'; - -COMMENT ON TABLE workspace_app_audit_sessions IS 'Audit sessions for workspace apps, the data in this table is ephemeral and is used to deduplicate audit log entries for workspace apps. While a session is active, the same data will not be logged again. This table does not store historical data.'; diff --git a/coderd/database/migrations/000556_connection_type_tunnel.up.sql b/coderd/database/migrations/000556_connection_type_tunnel.up.sql index 4afaf5f49fb..3a95492192d 100644 --- a/coderd/database/migrations/000556_connection_type_tunnel.up.sql +++ b/coderd/database/migrations/000556_connection_type_tunnel.up.sql @@ -1,13 +1 @@ ALTER TYPE connection_type ADD VALUE IF NOT EXISTS 'tunnel'; - -COMMENT ON COLUMN connection_logs.user_agent IS 'Null for agent-reported (SSH) events. For coderd-reported connections (workspace_app, port_forwarding, tunnel), this is the User-Agent header from the request.'; - -COMMENT ON COLUMN connection_logs.user_id IS 'Null for agent-reported (SSH) events. For coderd-reported connections (workspace_app, port_forwarding, tunnel), this is the ID of the user that made the request.'; - -COMMENT ON COLUMN connection_logs.slug_or_port IS 'Null for agent-reported (SSH) events and tunnel events. For workspace_app events, this is the slug of the app. For port_forwarding events, this is the port number being forwarded.'; - -COMMENT ON COLUMN connection_logs.disconnect_time IS 'The time the connection was closed. Null for coderd-reported connections (workspace_app, port_forwarding, tunnel). For agent-reported connections, this is null until we receive a disconnect event for the same connection_id.'; - -COMMENT ON COLUMN connection_logs.disconnect_reason IS 'The reason the connection was closed. Null for coderd-reported connections (workspace_app, port_forwarding, tunnel). For agent-reported connections, this is null until we receive a disconnect event for the same connection_id.'; - -COMMENT ON TABLE workspace_app_audit_sessions IS 'Audit sessions for workspace apps, ports, and tunnels. The data in this table is ephemeral and is used to deduplicate connection log entries. While a session is active, the same data will not be logged again. This table does not store historical data.'; diff --git a/coderd/database/models.go b/coderd/database/models.go index aead09d6de6..56fb2e1e1f0 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -5241,17 +5241,17 @@ type ConnectionLog struct { Ip pqtype.Inet `db:"ip" json:"ip"` // Either the HTTP status code of the web request, or the exit code of an SSH connection. For non-web connections, this is Null until we receive a disconnect event for the same connection_id. Code sql.NullInt32 `db:"code" json:"code"` - // Null for agent-reported (SSH) events. For coderd-reported connections (workspace_app, port_forwarding, tunnel), this is the User-Agent header from the request. + // Null for SSH events. For web connections, this is the User-Agent header from the request. UserAgent sql.NullString `db:"user_agent" json:"user_agent"` - // Null for agent-reported (SSH) events. For coderd-reported connections (workspace_app, port_forwarding, tunnel), this is the ID of the user that made the request. + // Null for SSH events. For web connections, this is the ID of the user that made the request. UserID uuid.NullUUID `db:"user_id" json:"user_id"` - // Null for agent-reported (SSH) events and tunnel events. For workspace_app events, this is the slug of the app. For port_forwarding events, this is the port number being forwarded. + // Null for SSH events. For web connections, this is the slug of the app or the port number being forwarded. SlugOrPort sql.NullString `db:"slug_or_port" json:"slug_or_port"` // The SSH connection ID. Used to correlate connections and disconnections. As it originates from the agent, it is not guaranteed to be unique. ConnectionID uuid.NullUUID `db:"connection_id" json:"connection_id"` - // The time the connection was closed. Null for coderd-reported connections (workspace_app, port_forwarding, tunnel). For agent-reported connections, this is null until we receive a disconnect event for the same connection_id. + // The time the connection was closed. Null for web connections. For other connections, this is null until we receive a disconnect event for the same connection_id. DisconnectTime sql.NullTime `db:"disconnect_time" json:"disconnect_time"` - // The reason the connection was closed. Null for coderd-reported connections (workspace_app, port_forwarding, tunnel). For agent-reported connections, this is null until we receive a disconnect event for the same connection_id. + // The reason the connection was closed. Null for web connections. For other connections, this is null until we receive a disconnect event for the same connection_id. DisconnectReason sql.NullString `db:"disconnect_reason" json:"disconnect_reason"` } @@ -6555,7 +6555,7 @@ type WorkspaceApp struct { Tooltip string `db:"tooltip" json:"tooltip"` } -// Audit sessions for workspace apps, ports, and tunnels. The data in this table is ephemeral and is used to deduplicate connection log entries. While a session is active, the same data will not be logged again. This table does not store historical data. +// Audit sessions for workspace apps, the data in this table is ephemeral and is used to deduplicate audit log entries for workspace apps. While a session is active, the same data will not be logged again. This table does not store historical data. type WorkspaceAppAuditSession struct { // The agent that the workspace app or port forward belongs to. AgentID uuid.UUID `db:"agent_id" json:"agent_id"` From 3192030eb8866a04ad1563539062873e53539c36 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 28 Jul 2026 17:12:04 +0000 Subject: [PATCH 08/11] refactor(coderd): simplify tunnel connection log contexts Use one named writeCtx for both the audit session upsert and the connection log enqueue instead of a shadowed ctx plus a separate enqueue deadline, inline the system-restricted context, and rename newOrStale to newSession. The shared-budget tradeoff (a slow session upsert can drop the log row until the session goes stale) is documented at the timeout. --- coderd/workspaceagents.go | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 04f4c46a0fc..59314bcb0c6 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -1405,17 +1405,11 @@ func (api *API) logTunnelConnection(ctx context.Context, r *http.Request, waws d // Bound the writes so that connection log backpressure (e.g. a // wedged database write) cannot stall tunnel establishment. Losing // a log row under extreme backpressure is preferable to refusing - // tunnels; the errors below record the loss. - ctx, cancel := context.WithTimeout(ctx, 3*time.Second) - defer cancel() - // The connection log enqueue gets its own budget so that a slow - // audit session upsert cannot exhaust the deadline before the - // enqueue runs. Without this, the session row would be committed - // with no log row written, and the active session would suppress - // retries for the entire stale interval. WithoutCancel keeps the - // enqueue alive if the client disconnects mid-handshake. - logCtx, logCancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second) - defer logCancel() + // tunnels; the errors below record the loss. Both writes share + // this budget: if the session upsert consumes it, the log row is + // dropped and not retried until the session goes stale. + writeCtx, writeCancel := context.WithTimeout(ctx, 3*time.Second) + defer writeCancel() userAgent := r.UserAgent() now := dbtime.Now() @@ -1437,8 +1431,7 @@ func (api *API) logTunnelConnection(ctx context.Context, r *http.Request, waws d staleInterval = time.Hour } // nolint:gocritic // System context is needed to write audit sessions. - dangerousSystemCtx := dbauthz.AsSystemRestricted(ctx) - newOrStale, err := api.Database.UpsertWorkspaceAppAuditSession(dangerousSystemCtx, database.UpsertWorkspaceAppAuditSessionParams{ + newSession, err := api.Database.UpsertWorkspaceAppAuditSession(dbauthz.AsSystemRestricted(writeCtx), database.UpsertWorkspaceAppAuditSessionParams{ // Config. StaleIntervalMS: staleInterval.Milliseconds(), @@ -1465,14 +1458,14 @@ func (api *API) logTunnelConnection(ctx context.Context, r *http.Request, waws d // with the database. return } - if !newOrStale { + if !newSession { // An active session for this key was already logged; this // handshake is a reconnection. return } connLogger := *api.ConnectionLogger.Load() - err = connLogger.Upsert(logCtx, database.UpsertConnectionLogParams{ + err = connLogger.Upsert(writeCtx, database.UpsertConnectionLogParams{ ID: uuid.New(), Time: now, OrganizationID: waws.WorkspaceTable.OrganizationID, From bb24335916bcb2eee001d81aa821ae8bed105935 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 28 Jul 2026 17:20:12 +0000 Subject: [PATCH 09/11] chore: make tunnel connection log comments succinct --- coderd/workspaceagents.go | 51 ++++++++++++--------------------------- codersdk/connectionlog.go | 12 +++------ 2 files changed, 19 insertions(+), 44 deletions(-) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 59314bcb0c6..03791570806 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -1389,43 +1389,27 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R } } -// logTunnelConnection records a connection log entry for a tunnel to a -// workspace agent so that enterprise auditors can attribute subsequent -// SSH/IDE activity inside the workspace back to the Coder user and -// client that established it. The agent-reported SSH connection log -// rows do not have this information (see -// coderd/agentapi/connectionlog.go). We only log when the caller is an -// authenticated user; requests proxied by a workspace proxy carry no -// API key on this route. +// 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 } - // Bound the writes so that connection log backpressure (e.g. a - // wedged database write) cannot stall tunnel establishment. Losing - // a log row under extreme backpressure is preferable to refusing - // tunnels; the errors below record the loss. Both writes share - // this budget: if the session upsert consumes it, the log row is - // dropped and not retried until the session goes stale. + // 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 automatically re-dial the coordinate endpoint after - // network blips, load balancer timeouts, and coderd restarts, so a - // row per handshake would flood the connection log with reconnect - // noise. Deduplicate through the same audit session mechanism used - // by workspace apps: only log when there is no active session for - // this (agent, user, IP, client) key. Tunnel sessions use uuid.Nil - // for app_id and an empty slug like port forwarding does. The - // status code is part of the session unique key: app and port - // forwarding sessions record the HTTP status of the token - // authorization (200, 4xx, ...), never 101, while tunnel sessions - // always record 101 (the WebSocket upgrade), so a tunnel session - // can never upsert into an app session row even when the agent, - // user, IP, and user agent all match. + // 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 @@ -1448,19 +1432,16 @@ func (api *API) logTunnelConnection(ctx context.Context, r *http.Request, waws d 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), ) - // Avoid spamming the connection log if deduplication failed; - // this should only happen if there are problems communicating - // with the database. return } if !newSession { - // An active session for this key was already logged; this - // handshake is a reconnection. + // Reconnection of an already-logged session. return } @@ -1481,10 +1462,8 @@ func (api *API) logTunnelConnection(ctx context.Context, r *http.Request, waws d }, UserAgent: sql.NullString{String: userAgent, Valid: userAgent != ""}, UserID: uuid.NullUUID{UUID: apiKey.UserID, Valid: true}, - // ConnectionID is intentionally left unset so that each - // new or stale audit session produces its own row. Reusing - // peerID here would cause resume_token reconnects to upsert - // into the existing row without updating ip/user_agent. + // 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 diff --git a/codersdk/connectionlog.go b/codersdk/connectionlog.go index 617b735a411..344c0c66927 100644 --- a/codersdk/connectionlog.go +++ b/codersdk/connectionlog.go @@ -47,14 +47,10 @@ const ( ConnectionTypeReconnectingPTY ConnectionType = "reconnecting_pty" ConnectionTypeWorkspaceApp ConnectionType = "workspace_app" ConnectionTypePortForwarding ConnectionType = "port_forwarding" - // ConnectionTypeTunnel is recorded when a client establishes a - // tailnet tunnel to a workspace agent via the workspace agent - // coordinate endpoint. Unlike the agent-reported types (ssh, - // vscode, jetbrains, reconnecting_pty), which cannot identify the - // connecting user, this event is written by coderd and carries the - // authenticated user's identity. Tunnels established through the - // user-scoped tailnet API (e.g. Coder Desktop) are not currently - // recorded. + // 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" ) From 14bf16c1e03b5f580c843a2421ebeeeb3b7bd25e Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 28 Jul 2026 17:27:13 +0000 Subject: [PATCH 10/11] chore(coderd/database): renumber tunnel migration to 000557 Main gained 000556_user_secrets_enabled. --- ...ype_tunnel.down.sql => 000557_connection_type_tunnel.down.sql} | 0 ...on_type_tunnel.up.sql => 000557_connection_type_tunnel.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000556_connection_type_tunnel.down.sql => 000557_connection_type_tunnel.down.sql} (100%) rename coderd/database/migrations/{000556_connection_type_tunnel.up.sql => 000557_connection_type_tunnel.up.sql} (100%) diff --git a/coderd/database/migrations/000556_connection_type_tunnel.down.sql b/coderd/database/migrations/000557_connection_type_tunnel.down.sql similarity index 100% rename from coderd/database/migrations/000556_connection_type_tunnel.down.sql rename to coderd/database/migrations/000557_connection_type_tunnel.down.sql diff --git a/coderd/database/migrations/000556_connection_type_tunnel.up.sql b/coderd/database/migrations/000557_connection_type_tunnel.up.sql similarity index 100% rename from coderd/database/migrations/000556_connection_type_tunnel.up.sql rename to coderd/database/migrations/000557_connection_type_tunnel.up.sql From 8c22808ce242fd20aa711265f8c2b63262f890fe Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 28 Jul 2026 19:56:56 +0000 Subject: [PATCH 11/11] db changes --- coderd/workspaceapps/db.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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),