feat: log tailnet tunnels to the connection log (#27423)

Co-authored-by: Chris DiGiamo <cd@anthropic.com>
Co-authored-by: Chris DiGiamo <cdigiamo@anthropic.com>
This commit is contained in:
Jon Ayers
2026-07-28 15:30:12 -05:00
committed by GitHub
co-authored by Chris DiGiamo Chris DiGiamo
parent 8cc7f2bb0e
commit 1a6a8be96c
21 changed files with 381 additions and 30 deletions
+5 -3
View File
@@ -18461,7 +18461,7 @@ const docTemplate = `{
"$ref": "#/definitions/codersdk.ConnectionType"
},
"web_info": {
"description": "WebInfo is only set when ` + "`" + `type` + "`" + ` is one of:\n- ` + "`" + `ConnectionTypePortForwarding` + "`" + `\n- ` + "`" + `ConnectionTypeWorkspaceApp` + "`" + `",
"description": "WebInfo is only set when ` + "`" + `type` + "`" + ` is one of:\n- ` + "`" + `ConnectionTypePortForwarding` + "`" + `\n- ` + "`" + `ConnectionTypeWorkspaceApp` + "`" + `\n- ` + "`" + `ConnectionTypeTunnel` + "`" + `",
"allOf": [
{
"$ref": "#/definitions/codersdk.ConnectionLogWebInfo"
@@ -18554,7 +18554,8 @@ const docTemplate = `{
"jetbrains",
"reconnecting_pty",
"workspace_app",
"port_forwarding"
"port_forwarding",
"tunnel"
],
"x-enum-varnames": [
"ConnectionTypeSSH",
@@ -18562,7 +18563,8 @@ const docTemplate = `{
"ConnectionTypeJetBrains",
"ConnectionTypeReconnectingPTY",
"ConnectionTypeWorkspaceApp",
"ConnectionTypePortForwarding"
"ConnectionTypePortForwarding",
"ConnectionTypeTunnel"
]
},
"codersdk.ConvertLoginRequest": {
+5 -3
View File
@@ -16667,7 +16667,7 @@
"$ref": "#/definitions/codersdk.ConnectionType"
},
"web_info": {
"description": "WebInfo is only set when `type` is one of:\n- `ConnectionTypePortForwarding`\n- `ConnectionTypeWorkspaceApp`",
"description": "WebInfo is only set when `type` is one of:\n- `ConnectionTypePortForwarding`\n- `ConnectionTypeWorkspaceApp`\n- `ConnectionTypeTunnel`",
"allOf": [
{
"$ref": "#/definitions/codersdk.ConnectionLogWebInfo"
@@ -16760,7 +16760,8 @@
"jetbrains",
"reconnecting_pty",
"workspace_app",
"port_forwarding"
"port_forwarding",
"tunnel"
],
"x-enum-varnames": [
"ConnectionTypeSSH",
@@ -16768,7 +16769,8 @@
"ConnectionTypeJetBrains",
"ConnectionTypeReconnectingPTY",
"ConnectionTypeWorkspaceApp",
"ConnectionTypePortForwarding"
"ConnectionTypePortForwarding",
"ConnectionTypeTunnel"
]
},
"codersdk.ConvertLoginRequest": {
+2 -1
View File
@@ -379,7 +379,8 @@ CREATE TYPE connection_type AS ENUM (
'jetbrains',
'reconnecting_pty',
'workspace_app',
'port_forwarding'
'port_forwarding',
'tunnel'
);
CREATE TYPE cors_behavior AS ENUM (
@@ -0,0 +1,8 @@
-- The 'tunnel' enum value is intentionally not removed. Postgres cannot
-- drop an enum value in place; removing it would require recreating
-- connection_type and rewriting the connection_logs type column, which
-- takes an exclusive lock on the table and would have to DELETE all
-- tunnel rows (audit data) because they cannot exist in the old type.
-- Leaving the value in place is harmless: old code never queries for it
-- and renders unknown types without error. This matches the precedent
-- of other enum-value additions (e.g. 000517, 000531).
@@ -0,0 +1 @@
ALTER TYPE connection_type ADD VALUE IF NOT EXISTS 'tunnel';
+4 -1
View File
@@ -1855,6 +1855,7 @@ const (
ConnectionTypeReconnectingPty ConnectionType = "reconnecting_pty"
ConnectionTypeWorkspaceApp ConnectionType = "workspace_app"
ConnectionTypePortForwarding ConnectionType = "port_forwarding"
ConnectionTypeTunnel ConnectionType = "tunnel"
)
func (e *ConnectionType) Scan(src interface{}) error {
@@ -1899,7 +1900,8 @@ func (e ConnectionType) Valid() bool {
ConnectionTypeJetbrains,
ConnectionTypeReconnectingPty,
ConnectionTypeWorkspaceApp,
ConnectionTypePortForwarding:
ConnectionTypePortForwarding,
ConnectionTypeTunnel:
return true
}
return false
@@ -1913,6 +1915,7 @@ func AllConnectionTypeValues() []ConnectionType {
ConnectionTypeReconnectingPty,
ConnectionTypeWorkspaceApp,
ConnectionTypePortForwarding,
ConnectionTypeTunnel,
}
}
+27 -6
View File
@@ -4049,6 +4049,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
@@ -4058,7 +4072,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,
},
},
{
@@ -4073,14 +4087,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",
@@ -4096,19 +4110,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",
@@ -4122,7 +4143,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",
+6 -4
View File
@@ -13742,8 +13742,9 @@ SELECT COUNT(*) AS count FROM (
WHEN $13 :: text != '' THEN
(($13 = 'ongoing' AND disconnect_time IS NULL) OR
($13 = 'completed' AND disconnect_time IS NOT NULL)) AND
-- Exclude web events, since we don't know their close time.
"type" NOT IN ('workspace_app', 'port_forwarding')
-- Exclude point-in-time events reported by coderd, since we
-- don't know their close time.
"type" NOT IN ('workspace_app', 'port_forwarding', 'tunnel')
ELSE true
END
-- Authorize Filter clause will be injected below in
@@ -13936,8 +13937,9 @@ WHERE
WHEN $13 :: text != '' THEN
(($13 = 'ongoing' AND disconnect_time IS NULL) OR
($13 = 'completed' AND disconnect_time IS NOT NULL)) AND
-- Exclude web events, since we don't know their close time.
"type" NOT IN ('workspace_app', 'port_forwarding')
-- Exclude point-in-time events reported by coderd, since we
-- don't know their close time.
"type" NOT IN ('workspace_app', 'port_forwarding', 'tunnel')
ELSE true
END
-- Authorize Filter clause will be injected below in
+6 -4
View File
@@ -115,8 +115,9 @@ WHERE
WHEN @status :: text != '' THEN
((@status = 'ongoing' AND disconnect_time IS NULL) OR
(@status = 'completed' AND disconnect_time IS NOT NULL)) AND
-- Exclude web events, since we don't know their close time.
"type" NOT IN ('workspace_app', 'port_forwarding')
-- Exclude point-in-time events reported by coderd, since we
-- don't know their close time.
"type" NOT IN ('workspace_app', 'port_forwarding', 'tunnel')
ELSE true
END
-- Authorize Filter clause will be injected below in
@@ -230,8 +231,9 @@ SELECT COUNT(*) AS count FROM (
WHEN @status :: text != '' THEN
((@status = 'ongoing' AND disconnect_time IS NULL) OR
(@status = 'completed' AND disconnect_time IS NOT NULL)) AND
-- Exclude web events, since we don't know their close time.
"type" NOT IN ('workspace_app', 'port_forwarding')
-- Exclude point-in-time events reported by coderd, since we
-- don't know their close time.
"type" NOT IN ('workspace_app', 'port_forwarding', 'tunnel')
ELSE true
END
-- Authorize Filter clause will be injected below in
+93
View File
@@ -1367,6 +1367,9 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R
})
return
}
api.logTunnelConnection(ctx, r, waws)
ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageBinary)
defer wsNetConn.Close()
@@ -1386,6 +1389,96 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R
}
}
// logTunnelConnection records a connection log entry attributing a
// tunnel to the authenticated user who opened it. Agent-reported rows
// cannot identify the user (see coderd/agentapi/connectionlog.go), and
// workspace-proxy-authenticated requests carry no API key and are
// skipped.
func (api *API) logTunnelConnection(ctx context.Context, r *http.Request, waws database.GetWorkspaceAgentAndWorkspaceByIDRow) {
apiKey, ok := httpmw.APIKeyOptional(r)
if !ok {
return
}
// Bounded so log backpressure cannot stall tunnel establishment.
writeCtx, writeCancel := context.WithTimeout(ctx, 3*time.Second)
defer writeCancel()
userAgent := r.UserAgent()
now := dbtime.Now()
// Clients re-dial automatically, so dedupe reconnects through the
// same audit session mechanism as workspace apps, keyed on
// (agent, user, IP, user agent). Status 101 and the empty slug
// keep tunnel sessions from ever colliding with app or
// port-forwarding sessions.
staleInterval := api.Options.WorkspaceAppAuditSessionTimeout
if staleInterval == 0 {
staleInterval = time.Hour
}
// nolint:gocritic // System context is needed to write audit sessions.
newSession, err := api.Database.UpsertWorkspaceAppAuditSession(dbauthz.AsSystemRestricted(writeCtx), database.UpsertWorkspaceAppAuditSessionParams{
// Config.
StaleIntervalMS: staleInterval.Milliseconds(),
// Data.
ID: uuid.New(),
AgentID: waws.WorkspaceAgent.ID,
AppID: uuid.Nil, // Tunnels are not associated with an app.
UserID: apiKey.UserID,
Ip: r.RemoteAddr,
UserAgent: userAgent,
SlugOrPort: "",
StatusCode: http.StatusSwitchingProtocols,
StartedAt: now,
UpdatedAt: now,
})
if err != nil {
// Skip logging rather than risk spamming the connection log.
api.Logger.Error(ctx, "upsert tunnel audit session",
slog.F("workspace_id", waws.WorkspaceTable.ID),
slog.F("user_id", apiKey.UserID),
slog.Error(err),
)
return
}
if !newSession {
// Reconnection of an already-logged session.
return
}
connLogger := *api.ConnectionLogger.Load()
err = connLogger.Upsert(writeCtx, database.UpsertConnectionLogParams{
ID: uuid.New(),
Time: now,
OrganizationID: waws.WorkspaceTable.OrganizationID,
WorkspaceOwnerID: waws.WorkspaceTable.OwnerID,
WorkspaceID: waws.WorkspaceTable.ID,
WorkspaceName: waws.WorkspaceTable.Name,
AgentName: waws.WorkspaceAgent.Name,
Type: database.ConnectionTypeTunnel,
IP: database.ParseIP(r.RemoteAddr),
Code: sql.NullInt32{
Int32: http.StatusSwitchingProtocols,
Valid: true,
},
UserAgent: sql.NullString{String: userAgent, Valid: userAgent != ""},
UserID: uuid.NullUUID{UUID: apiKey.UserID, Valid: true},
// Left unset so each session gets its own row; reusing peerID
// would make resume_token reconnects upsert into a stale row.
ConnectionID: uuid.NullUUID{},
ConnectionStatus: database.ConnectionStatusConnected,
// N/A
SlugOrPort: sql.NullString{},
DisconnectReason: sql.NullString{},
})
if err != nil {
api.Logger.Error(ctx, "upsert tunnel connection log",
slog.F("workspace_id", waws.WorkspaceTable.ID),
slog.F("user_id", apiKey.UserID),
slog.Error(err),
)
}
}
// handleResumeToken accepts a resume_token query parameter to use the same peer ID
func (api *API) handleResumeToken(ctx context.Context, rw http.ResponseWriter, r *http.Request) (peerID uuid.UUID, err error) {
peerID = uuid.New()
+72
View File
@@ -43,6 +43,7 @@ import (
"github.com/coder/coder/v2/coderd/agentapi/metadatabatcher"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
"github.com/coder/coder/v2/coderd/connectionlog"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
@@ -919,6 +920,77 @@ func TestWorkspaceAgentTailnet(t *testing.T) {
require.Equal(t, "test", strings.TrimSpace(string(output)))
}
func TestWorkspaceAgentClientCoordinate_ConnectionLog(t *testing.T) {
t.Parallel()
connLogger := connectionlog.NewFake()
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
ConnectionLogger: connLogger,
})
user := coderdtest.CreateFirstUser(t, client)
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
}).WithAgent().Do()
_ = agenttest.New(t, client.URL, r.AgentToken)
resources := coderdtest.AwaitWorkspaceAgents(t, client, r.Workspace.ID)
ctx := testutil.Context(t, testutil.WaitLong)
conn, err := workspacesdk.New(client).
DialAgent(ctx, resources[0].Agents[0].ID, &workspacesdk.DialAgentOptions{
Logger: testutil.Logger(t).Named("client"),
})
require.NoError(t, err)
defer conn.Close()
require.True(t, conn.AwaitReachable(ctx))
require.Eventually(t, func() bool {
return connLogger.Contains(t, database.UpsertConnectionLogParams{
OrganizationID: user.OrganizationID,
WorkspaceOwnerID: user.UserID,
WorkspaceID: r.Workspace.ID,
WorkspaceName: r.Workspace.Name,
AgentName: resources[0].Agents[0].Name,
Type: database.ConnectionTypeTunnel,
Code: sql.NullInt32{
Int32: http.StatusSwitchingProtocols,
Valid: true,
},
ConnectionStatus: database.ConnectionStatusConnected,
UserID: uuid.NullUUID{
UUID: user.UserID,
Valid: true,
},
})
}, testutil.WaitShort, testutil.IntervalFast)
err = conn.Close()
require.NoError(t, err)
// A second handshake within the audit session stale interval is a
// reconnection and must be deduplicated rather than producing a
// second row.
conn2, err := workspacesdk.New(client).
DialAgent(ctx, resources[0].Agents[0].ID, &workspacesdk.DialAgentOptions{
Logger: testutil.Logger(t).Named("client2"),
})
require.NoError(t, err)
defer conn2.Close()
// The connection log write happens in the coordinate handler
// before any coordination traffic is served, so once the tunnel is
// reachable the second handshake has already been processed.
require.True(t, conn2.AwaitReachable(ctx))
tunnelRows := 0
for _, cl := range connLogger.ConnectionLogs() {
if cl.Type == database.ConnectionTypeTunnel {
tunnelRows++
}
}
require.Equal(t, 1, tunnelRows)
}
func TestWorkspaceAgentClientCoordinate_BadVersion(t *testing.T) {
t.Parallel()
client, db := coderdtest.NewWithDatabase(t, nil)
+14
View File
@@ -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),