From 44f36a38d92fe66302832a9d467f94992aab549e Mon Sep 17 00:00:00 2001 From: Chris DiGiamo Date: Mon, 6 Jul 2026 17:00:38 +0000 Subject: [PATCH] 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 b423dca3c7d..0510ae105e2 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -17931,7 +17931,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" @@ -18024,7 +18024,8 @@ const docTemplate = `{ "jetbrains", "reconnecting_pty", "workspace_app", - "port_forwarding" + "port_forwarding", + "tailnet" ], "x-enum-varnames": [ "ConnectionTypeSSH", @@ -18032,7 +18033,8 @@ const docTemplate = `{ "ConnectionTypeJetBrains", "ConnectionTypeReconnectingPTY", "ConnectionTypeWorkspaceApp", - "ConnectionTypePortForwarding" + "ConnectionTypePortForwarding", + "ConnectionTypeTailnet" ] }, "codersdk.ConvertLoginRequest": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 811633d02b0..57b831d1d9c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -16182,7 +16182,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" @@ -16275,7 +16275,8 @@ "jetbrains", "reconnecting_pty", "workspace_app", - "port_forwarding" + "port_forwarding", + "tailnet" ], "x-enum-varnames": [ "ConnectionTypeSSH", @@ -16283,7 +16284,8 @@ "ConnectionTypeJetBrains", "ConnectionTypeReconnectingPTY", "ConnectionTypeWorkspaceApp", - "ConnectionTypePortForwarding" + "ConnectionTypePortForwarding", + "ConnectionTypeTailnet" ] }, "codersdk.ConvertLoginRequest": { diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index cae4c52cba6..d8b19fecf26 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -357,7 +357,8 @@ CREATE TYPE connection_type AS ENUM ( 'jetbrains', 'reconnecting_pty', 'workspace_app', - 'port_forwarding' + 'port_forwarding', + 'tailnet' ); CREATE TYPE cors_behavior AS ENUM ( @@ -2181,9 +2182,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 6cfee5f9318..4b99a139b3c 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1703,6 +1703,7 @@ const ( ConnectionTypeReconnectingPty ConnectionType = "reconnecting_pty" ConnectionTypeWorkspaceApp ConnectionType = "workspace_app" ConnectionTypePortForwarding ConnectionType = "port_forwarding" + ConnectionTypeTailnet ConnectionType = "tailnet" ) func (e *ConnectionType) Scan(src interface{}) error { @@ -1747,7 +1748,8 @@ func (e ConnectionType) Valid() bool { ConnectionTypeJetbrains, ConnectionTypeReconnectingPty, ConnectionTypeWorkspaceApp, - ConnectionTypePortForwarding: + ConnectionTypePortForwarding, + ConnectionTypeTailnet: return true } return false @@ -1761,6 +1763,7 @@ func AllConnectionTypeValues() []ConnectionType { ConnectionTypeReconnectingPty, ConnectionTypeWorkspaceApp, ConnectionTypePortForwarding, + ConnectionTypeTailnet, } } @@ -5067,9 +5070,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 d23291dabe1..641b6bfa920 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -13067,7 +13067,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 @@ -13261,7 +13261,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 915cd2ac909..a0447da101d 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 1e52d1e35cf..df71b3530b4 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -40,6 +40,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" @@ -916,6 +917,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 4d9124782bd..ee55e6207df 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -4109,7 +4109,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 | | | @@ -4261,9 +4261,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 84a8a8c3ae4..ff506871aee 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3429,6 +3429,7 @@ export type ConnectionType = | "port_forwarding" | "reconnecting_pty" | "ssh" + | "tailnet" | "vscode" | "workspace_app"; @@ -3437,6 +3438,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":