@@ -844,31 +844,10 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R
844844return
845845}
846846
847- // Accept a resume_token query parameter to use the same peer ID.
848- var (
849- peerID = uuid .New ()
850- resumeToken = r .URL .Query ().Get ("resume_token" )
851- )
852- if resumeToken != "" {
853- var err error
854- peerID ,err = api .Options .CoordinatorResumeTokenProvider .VerifyResumeToken (ctx ,resumeToken )
855- // If the token is missing the key ID, it's probably an old token in which
856- // case we just want to generate a new peer ID.
857- if xerrors .Is (err ,jwtutils .ErrMissingKeyID ) {
858- peerID = uuid .New ()
859- }else if err != nil {
860- httpapi .Write (ctx ,rw ,http .StatusUnauthorized , codersdk.Response {
861- Message :workspacesdk .CoordinateAPIInvalidResumeToken ,
862- Detail :err .Error (),
863- Validations : []codersdk.ValidationError {
864- {Field :"resume_token" ,Detail :workspacesdk .CoordinateAPIInvalidResumeToken },
865- },
866- })
867- return
868- }else {
869- api .Logger .Debug (ctx ,"accepted coordinate resume token for peer" ,
870- slog .F ("peer_id" ,peerID .String ()))
871- }
847+ peerID ,err := api .handleResumeToken (ctx ,rw ,r )
848+ if err != nil {
849+ // handleResumeToken has already written the response.
850+ return
872851}
873852
874853api .WebsocketWaitMutex .Lock ()
@@ -898,6 +877,33 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R
898877}
899878}
900879
880+ // handleResumeToken accepts a resume_token query parameter to use the same peer ID
881+ func (api * API )handleResumeToken (ctx context.Context ,rw http.ResponseWriter ,r * http.Request ) (peerID uuid.UUID ,err error ) {
882+ peerID = uuid .New ()
883+ resumeToken := r .URL .Query ().Get ("resume_token" )
884+ if resumeToken != "" {
885+ peerID ,err = api .Options .CoordinatorResumeTokenProvider .VerifyResumeToken (ctx ,resumeToken )
886+ // If the token is missing the key ID, it's probably an old token in which
887+ // case we just want to generate a new peer ID.
888+ if xerrors .Is (err ,jwtutils .ErrMissingKeyID ) {
889+ peerID = uuid .New ()
890+ }else if err != nil {
891+ httpapi .Write (ctx ,rw ,http .StatusUnauthorized , codersdk.Response {
892+ Message :workspacesdk .CoordinateAPIInvalidResumeToken ,
893+ Detail :err .Error (),
894+ Validations : []codersdk.ValidationError {
895+ {Field :"resume_token" ,Detail :workspacesdk .CoordinateAPIInvalidResumeToken },
896+ },
897+ })
898+ return
899+ }else {
900+ api .Logger .Debug (ctx ,"accepted coordinate resume token for peer" ,
901+ slog .F ("peer_id" ,peerID .String ()))
902+ }
903+ }
904+ return peerID ,err
905+ }
906+
901907// @Summary Post workspace agent log source
902908// @ID post-workspace-agent-log-source
903909// @Security CoderSessionToken
@@ -1469,6 +1475,72 @@ func (api *API) workspaceAgentsExternalAuthListen(ctx context.Context, rw http.R
14691475}
14701476}
14711477
1478+ // @Summary Coordinate multiple workspace agents
1479+ // @ID coordinate-multiple-workspace-agents
1480+ // @Security CoderSessionToken
1481+ // @Tags Agents
1482+ // @Success 101
1483+ // @Router /users/me/tailnet [get]
1484+ func (api * API )tailnet (rw http.ResponseWriter ,r * http.Request ) {
1485+ ctx := r .Context ()
1486+ apiKey ,ok := httpmw .APIKeyOptional (r )
1487+ if ! ok {
1488+ httpapi .Write (ctx ,rw ,http .StatusBadRequest , codersdk.Response {
1489+ Message :"Cannot use\" me\" without a valid session." ,
1490+ })
1491+ return
1492+ }
1493+
1494+ version := "2.0"
1495+ qv := r .URL .Query ().Get ("version" )
1496+ if qv != "" {
1497+ version = qv
1498+ }
1499+ if err := proto .CurrentVersion .Validate (version );err != nil {
1500+ httpapi .Write (ctx ,rw ,http .StatusBadRequest , codersdk.Response {
1501+ Message :"Unknown or unsupported API version" ,
1502+ Validations : []codersdk.ValidationError {
1503+ {Field :"version" ,Detail :err .Error ()},
1504+ },
1505+ })
1506+ return
1507+ }
1508+
1509+ peerID ,err := api .handleResumeToken (ctx ,rw ,r )
1510+ if err != nil {
1511+ // handleResumeToken has already written the response.
1512+ return
1513+ }
1514+
1515+ api .WebsocketWaitMutex .Lock ()
1516+ api .WebsocketWaitGroup .Add (1 )
1517+ api .WebsocketWaitMutex .Unlock ()
1518+ defer api .WebsocketWaitGroup .Done ()
1519+
1520+ conn ,err := websocket .Accept (rw ,r ,nil )
1521+ if err != nil {
1522+ httpapi .Write (ctx ,rw ,http .StatusBadRequest , codersdk.Response {
1523+ Message :"Failed to accept websocket." ,
1524+ Detail :err .Error (),
1525+ })
1526+ return
1527+ }
1528+ ctx ,wsNetConn := codersdk .WebsocketNetConn (ctx ,conn ,websocket .MessageBinary )
1529+ defer wsNetConn .Close ()
1530+ defer conn .Close (websocket .StatusNormalClosure ,"" )
1531+
1532+ go httpapi .Heartbeat (ctx ,conn )
1533+ err = api .TailnetClientService .ServeUserClient (ctx ,version ,wsNetConn , tailnet.ServeUserClientOptions {
1534+ PeerID :peerID ,
1535+ UserID :apiKey .UserID ,
1536+ UpdatesProvider :api .WorkspaceUpdatesProvider ,
1537+ })
1538+ if err != nil && ! xerrors .Is (err ,io .EOF )&& ! xerrors .Is (err ,context .Canceled ) {
1539+ _ = conn .Close (websocket .StatusInternalError ,err .Error ())
1540+ return
1541+ }
1542+ }
1543+
14721544// createExternalAuthResponse creates an ExternalAuthResponse based on the
14731545// provider type. This is to support legacy `/workspaceagents/me/gitauth`
14741546// which uses `Username` and `Password`.