66"encoding/json"
77"errors"
88"fmt"
9+ "io"
910"net/http"
1011"slices"
1112"strconv"
@@ -15,6 +16,7 @@ import (
1516"github.com/go-chi/chi/v5"
1617"github.com/google/uuid"
1718"golang.org/x/xerrors"
19+ "nhooyr.io/websocket"
1820
1921"cdr.dev/slog"
2022"github.com/coder/coder/v2/agent/proto"
@@ -36,6 +38,7 @@ import (
3638"github.com/coder/coder/v2/coderd/wsbuilder"
3739"github.com/coder/coder/v2/codersdk"
3840"github.com/coder/coder/v2/codersdk/agentsdk"
41+ "github.com/coder/coder/v2/tailnet"
3942)
4043
4144var (
@@ -2068,6 +2071,11 @@ func (api *API) publishWorkspaceUpdate(ctx context.Context, workspaceID uuid.UUI
20682071api .Logger .Warn (ctx ,"failed to publish workspace update" ,
20692072slog .F ("workspace_id" ,workspaceID ),slog .Error (err ))
20702073}
2074+ err = api .Pubsub .Publish (codersdk .AllWorkspacesNotifyChannel , []byte (workspaceID .String ()))
2075+ if err != nil {
2076+ api .Logger .Warn (ctx ,"failed to publish all workspaces update" ,
2077+ slog .F ("workspace_id" ,workspaceID ),slog .Error (err ))
2078+ }
20712079}
20722080
20732081func (api * API )publishWorkspaceAgentLogsUpdate (ctx context.Context ,workspaceAgentID uuid.UUID ,m agentsdk.LogsNotifyMessage ) {
@@ -2080,3 +2088,72 @@ func (api *API) publishWorkspaceAgentLogsUpdate(ctx context.Context, workspaceAg
20802088api .Logger .Warn (ctx ,"failed to publish workspace agent logs update" ,slog .F ("workspace_agent_id" ,workspaceAgentID ),slog .Error (err ))
20812089}
20822090}
2091+
2092+ // @Summary Coordinate multiple workspace agents
2093+ // @ID coordinate-multiple-workspace-agents
2094+ // @Security CoderSessionToken
2095+ // @Tags Workspaces
2096+ // @Success 101
2097+ // @Router /users/me/tailnet [get]
2098+ func (api * API )tailnet (rw http.ResponseWriter ,r * http.Request ) {
2099+ ctx := r .Context ()
2100+ owner := httpmw .UserParam (r )
2101+ ownerRoles := httpmw .UserAuthorization (r )
2102+
2103+ // Check if the actor is allowed to access any workspace owned by the user.
2104+ if ! api .Authorize (r ,policy .ActionSSH ,rbac .ResourceWorkspace .WithOwner (owner .ID .String ())) {
2105+ httpapi .ResourceNotFound (rw )
2106+ return
2107+ }
2108+
2109+ version := "1.0"
2110+ qv := r .URL .Query ().Get ("version" )
2111+ if qv != "" {
2112+ version = qv
2113+ }
2114+ if err := proto .CurrentVersion .Validate (version );err != nil {
2115+ httpapi .Write (ctx ,rw ,http .StatusBadRequest , codersdk.Response {
2116+ Message :"Unknown or unsupported API version" ,
2117+ Validations : []codersdk.ValidationError {
2118+ {Field :"version" ,Detail :err .Error ()},
2119+ },
2120+ })
2121+ return
2122+ }
2123+
2124+ peerID ,err := api .handleResumeToken (ctx ,rw ,r )
2125+ if err != nil {
2126+ // handleResumeToken has already written the response.
2127+ return
2128+ }
2129+
2130+ api .WebsocketWaitMutex .Lock ()
2131+ api .WebsocketWaitGroup .Add (1 )
2132+ api .WebsocketWaitMutex .Unlock ()
2133+ defer api .WebsocketWaitGroup .Done ()
2134+
2135+ conn ,err := websocket .Accept (rw ,r ,nil )
2136+ if err != nil {
2137+ httpapi .Write (ctx ,rw ,http .StatusBadRequest , codersdk.Response {
2138+ Message :"Failed to accept websocket." ,
2139+ Detail :err .Error (),
2140+ })
2141+ return
2142+ }
2143+ ctx ,wsNetConn := codersdk .WebsocketNetConn (ctx ,conn ,websocket .MessageBinary )
2144+ defer wsNetConn .Close ()
2145+ defer conn .Close (websocket .StatusNormalClosure ,"" )
2146+
2147+ go httpapi .Heartbeat (ctx ,conn )
2148+ err = api .TailnetClientService .ServeUserClient (ctx ,version ,wsNetConn , tailnet.ServeUserClientOptions {
2149+ PeerID :peerID ,
2150+ UserID :owner .ID ,
2151+ Subject :& ownerRoles ,
2152+ Authz :api .Authorizer ,
2153+ Database :api .Database ,
2154+ })
2155+ if err != nil && ! xerrors .Is (err ,io .EOF )&& ! xerrors .Is (err ,context .Canceled ) {
2156+ _ = conn .Close (websocket .StatusInternalError ,err .Error ())
2157+ return
2158+ }
2159+ }