Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit b251098

Browse files
committed
feat: Add browser-only connections to Enterprise
Fixes #4131.
1 parent 3618b09 commit b251098

File tree

12 files changed

+215
-22
lines changed

12 files changed

+215
-22
lines changed

coderd/coderd.go

+3-2
Original file line numberDiff line numberDiff line change
@@ -493,8 +493,9 @@ func New(options *Options) *API {
493493

494494
type API struct {
495495
*Options
496-
Auditor atomic.Pointer[audit.Auditor]
497-
HTTPAuth *HTTPAuthorizer
496+
Auditor atomic.Pointer[audit.Auditor]
497+
WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool]
498+
HTTPAuth *HTTPAuthorizer
498499

499500
// APIHandler serves "/api/v2"
500501
APIHandler chi.Router

coderd/workspaceagents.go

+6-1
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ func (api *API) workspaceAgentCoordinate(rw http.ResponseWriter, r *http.Request
384384
}
385385
}
386386

387-
// workspaceAgentClientCoordinate accepts a WebSocket that reads node network updates.
387+
// WorkspaceAgentClientCoordinate accepts a WebSocket that reads node network updates.
388388
// After accept a PubSub starts listening for new connection node updates
389389
// which are written to the WebSocket.
390390
func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.Request) {
@@ -393,6 +393,11 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R
393393
httpapi.ResourceNotFound(rw)
394394
return
395395
}
396+
// This is used by Enterprise code to control the functionality of this route.
397+
override := api.WorkspaceClientCoordinateOverride.Load()
398+
if override != nil && (*override)(rw) {
399+
return
400+
}
396401

397402
api.websocketWaitMutex.Lock()
398403
api.websocketWaitGroup.Add(1)

codersdk/features.go

+4-3
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@ const (
1515
)
1616

1717
const (
18-
FeatureUserLimit = "user_limit"
19-
FeatureAuditLog = "audit_log"
18+
FeatureUserLimit = "user_limit"
19+
FeatureAuditLog = "audit_log"
20+
FeatureBrowserOnly = "browser_only"
2021
)
2122

22-
var FeatureNames = []string{FeatureUserLimit, FeatureAuditLog}
23+
var FeatureNames = []string{FeatureUserLimit, FeatureAuditLog, FeatureBrowserOnly}
2324

2425
type Feature struct {
2526
Entitlement Entitlement `json:"entitlement"`

codersdk/workspaceagents.go

+22-3
Original file line numberDiff line numberDiff line change
@@ -270,12 +270,14 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg
270270
}
271271
ctx, cancelFunc := context.WithCancel(ctx)
272272
closed := make(chan struct{})
273+
first := make(chan error)
273274
go func() {
274275
defer close(closed)
276+
isFirst := true
275277
for retrier := retry.New(50*time.Millisecond, 10*time.Second); retrier.Wait(ctx); {
276278
logger.Debug(ctx, "connecting")
277279
// nolint:bodyclose
278-
ws, _, err := websocket.Dial(ctx, coordinateURL.String(), &websocket.DialOptions{
280+
ws, res, err := websocket.Dial(ctx, coordinateURL.String(), &websocket.DialOptions{
279281
HTTPClient: httpClient,
280282
// Need to disable compression to avoid a data-race.
281283
CompressionMode: websocket.CompressionDisabled,
@@ -284,11 +286,22 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg
284286
_ = ws.Close(websocket.StatusAbnormalClosure, "")
285287
return
286288
}
289+
if isFirst {
290+
if res.StatusCode == http.StatusConflict {
291+
first <- readBodyAsError(res)
292+
return
293+
}
294+
isFirst = false
295+
close(first)
296+
}
287297
if err != nil {
288298
logger.Debug(ctx, "failed to dial", slog.Error(err))
289-
_ = ws.Close(websocket.StatusAbnormalClosure, "")
290299
continue
291300
}
301+
if isFirst {
302+
isFirst = false
303+
close(first)
304+
}
292305
sendNode, errChan := tailnet.ServeCoordinator(websocket.NetConn(ctx, ws, websocket.MessageBinary), func(node []*tailnet.Node) error {
293306
return conn.UpdateNodes(node)
294307
})
@@ -307,13 +320,19 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg
307320
_ = ws.Close(websocket.StatusAbnormalClosure, "")
308321
}
309322
}()
323+
err = <-first
324+
if err != nil {
325+
cancelFunc()
326+
_ = conn.Close()
327+
return nil, err
328+
}
310329
return &agent.Conn{
311330
Conn: conn,
312331
CloseFunc: func() {
313332
cancelFunc()
314333
<-closed
315334
},
316-
}, nil
335+
}, err
317336
}
318337

319338
// WorkspaceAgent returns an agent by ID.

docs/admin/enterprise.md

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Contact [email protected] to obtain a license.
66
These features are:
77

88
* Audit Logging
9+
* Browser Only Connections
910

1011
## Adding your license key
1112

enterprise/cli/server.go

+8-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/spf13/cobra"
77

88
"github.com/coder/coder/cli/cliflag"
9+
"github.com/coder/coder/cli/cliui"
910
"github.com/coder/coder/enterprise/coderd"
1011

1112
agpl "github.com/coder/coder/cli"
@@ -15,19 +16,25 @@ import (
1516
func server() *cobra.Command {
1617
var (
1718
auditLogging bool
19+
browserOnly bool
1820
)
1921
cmd := agpl.Server(func(ctx context.Context, options *agplcoderd.Options) (*agplcoderd.API, error) {
2022
api, err := coderd.New(ctx, &coderd.Options{
2123
AuditLogging: auditLogging,
24+
BrowserOnly: browserOnly,
2225
Options: options,
2326
})
2427
if err != nil {
2528
return nil, err
2629
}
2730
return api.AGPL, nil
2831
})
32+
enterpriseOnly := cliui.Styles.Keyword.Render("This is an Enterprise feature. Contact [email protected] for licensing")
33+
2934
cliflag.BoolVarP(cmd.Flags(), &auditLogging, "audit-logging", "", "CODER_AUDIT_LOGGING", true,
30-
"Specifies whether audit logging is enabled.")
35+
"Specifies whether audit logging is enabled. "+enterpriseOnly)
36+
cliflag.BoolVarP(cmd.Flags(), &browserOnly, "browser-only", "", "CODER_BROWSER_ONLY", false,
37+
"Whether Coder only allows connections to workspaces via the browser. "+enterpriseOnly)
3138

3239
return cmd
3340
}

enterprise/coderd/coderd.go

+25-3
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ func New(ctx context.Context, options *Options) (*API, error) {
5252
OIDC: options.OIDCConfig,
5353
}
5454
apiKeyMiddleware := httpmw.ExtractAPIKey(options.Database, oauthConfigs, false)
55-
5655
api.AGPL.APIHandler.Group(func(r chi.Router) {
5756
r.Get("/entitlements", api.serveEntitlements)
5857
r.Route("/licenses", func(r chi.Router) {
@@ -75,7 +74,9 @@ func New(ctx context.Context, options *Options) (*API, error) {
7574
type Options struct {
7675
*coderd.Options
7776

78-
AuditLogging bool
77+
AuditLogging bool
78+
// Whether to block non-browser connections.
79+
BrowserOnly bool
7980
EntitlementsUpdateInterval time.Duration
8081
Keys map[string]ed25519.PublicKey
8182
}
@@ -93,6 +94,7 @@ type entitlements struct {
9394
hasLicense bool
9495
activeUsers codersdk.Feature
9596
auditLogs codersdk.Entitlement
97+
browserOnly codersdk.Entitlement
9698
}
9799

98100
func (api *API) Close() error {
@@ -149,13 +151,16 @@ func (api *API) updateEntitlements(ctx context.Context) error {
149151
if claims.Features.AuditLog > 0 {
150152
entitlements.auditLogs = entitlement
151153
}
154+
if claims.Features.BrowserOnly > 0 {
155+
entitlements.browserOnly = entitlement
156+
}
152157
}
153158

154159
if entitlements.auditLogs != api.entitlements.auditLogs {
155160
auditor := agplaudit.NewNop()
156161
// A flag could be added to the options that would allow disabling
157162
// enhanced audit logging here!
158-
if entitlements.auditLogs == codersdk.EntitlementEntitled && api.AuditLogging {
163+
if entitlements.auditLogs != codersdk.EntitlementNotEntitled && api.AuditLogging {
159164
auditor = audit.NewAuditor(
160165
audit.DefaultFilter,
161166
backends.NewPostgres(api.Database, true),
@@ -165,6 +170,14 @@ func (api *API) updateEntitlements(ctx context.Context) error {
165170
api.AGPL.Auditor.Store(&auditor)
166171
}
167172

173+
if entitlements.browserOnly != api.entitlements.browserOnly {
174+
var handler func(rw http.ResponseWriter) bool
175+
if entitlements.browserOnly != codersdk.EntitlementNotEntitled && api.BrowserOnly {
176+
handler = api.shouldBlockNonBrowserConnections
177+
}
178+
api.AGPL.WorkspaceClientCoordinateOverride.Store(&handler)
179+
}
180+
168181
api.entitlements = entitlements
169182

170183
return nil
@@ -210,6 +223,15 @@ func (api *API) serveEntitlements(rw http.ResponseWriter, r *http.Request) {
210223
"Audit logging is enabled but your license for this feature is expired.")
211224
}
212225

226+
resp.Features[codersdk.FeatureBrowserOnly] = codersdk.Feature{
227+
Entitlement: entitlements.browserOnly,
228+
Enabled: api.BrowserOnly,
229+
}
230+
if entitlements.browserOnly == codersdk.EntitlementGracePeriod && api.BrowserOnly {
231+
resp.Warnings = append(resp.Warnings,
232+
"Browser only connections are enabled but your license for this feature is expired.")
233+
}
234+
213235
httpapi.Write(rw, http.StatusOK, resp)
214236
}
215237

enterprise/coderd/coderd_test.go

+15-5
Original file line numberDiff line numberDiff line change
@@ -84,15 +84,18 @@ func TestEntitlements(t *testing.T) {
8484
})
8585
t.Run("Warnings", func(t *testing.T) {
8686
t.Parallel()
87-
client := coderdenttest.New(t, nil)
87+
client := coderdenttest.New(t, &coderdenttest.Options{
88+
BrowserOnly: true,
89+
})
8890
first := coderdtest.CreateFirstUser(t, client)
8991
for i := 0; i < 4; i++ {
9092
coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
9193
}
9294
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
93-
UserLimit: 4,
94-
AuditLog: true,
95-
GraceAt: time.Now().Add(-time.Second),
95+
UserLimit: 4,
96+
AuditLog: true,
97+
BrowserOnly: true,
98+
GraceAt: time.Now().Add(-time.Second),
9699
})
97100
res, err := client.Entitlements(context.Background())
98101
require.NoError(t, err)
@@ -107,11 +110,18 @@ func TestEntitlements(t *testing.T) {
107110
assert.True(t, al.Enabled)
108111
assert.Nil(t, al.Limit)
109112
assert.Nil(t, al.Actual)
110-
assert.Len(t, res.Warnings, 2)
113+
bo := res.Features[codersdk.FeatureBrowserOnly]
114+
assert.Equal(t, codersdk.EntitlementGracePeriod, bo.Entitlement)
115+
assert.True(t, bo.Enabled)
116+
assert.Nil(t, bo.Limit)
117+
assert.Nil(t, bo.Actual)
118+
assert.Len(t, res.Warnings, 3)
111119
assert.Contains(t, res.Warnings,
112120
"Your deployment has 5 active users but is only licensed for 4.")
113121
assert.Contains(t, res.Warnings,
114122
"Audit logging is enabled but your license for this feature is expired.")
123+
assert.Contains(t, res.Warnings,
124+
"Browser only connections are enabled but your license for this feature is expired.")
115125
})
116126
t.Run("Pubsub", func(t *testing.T) {
117127
t.Parallel()

enterprise/coderd/coderdenttest/coderdenttest.go

+10-2
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ func init() {
3636

3737
type Options struct {
3838
*coderdtest.Options
39+
BrowserOnly bool
3940
EntitlementsUpdateInterval time.Duration
4041
}
4142

@@ -55,6 +56,7 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c
5556
srv, cancelFunc, oop := coderdtest.NewOptions(t, options.Options)
5657
coderAPI, err := coderd.New(context.Background(), &coderd.Options{
5758
AuditLogging: true,
59+
BrowserOnly: options.BrowserOnly,
5860
Options: oop,
5961
EntitlementsUpdateInterval: options.EntitlementsUpdateInterval,
6062
Keys: map[string]ed25519.PublicKey{
@@ -82,6 +84,7 @@ type LicenseOptions struct {
8284
ExpiresAt time.Time
8385
UserLimit int64
8486
AuditLog bool
87+
BrowserOnly bool
8588
}
8689

8790
// AddLicense generates a new license with the options provided and inserts it.
@@ -105,6 +108,10 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
105108
if options.AuditLog {
106109
auditLog = 1
107110
}
111+
browserOnly := int64(0)
112+
if options.BrowserOnly {
113+
browserOnly = 1
114+
}
108115
c := &coderd.Claims{
109116
RegisteredClaims: jwt.RegisteredClaims{
110117
Issuer: "[email protected]",
@@ -117,8 +124,9 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
117124
AccountID: options.AccountID,
118125
Version: coderd.CurrentVersion,
119126
Features: coderd.Features{
120-
UserLimit: options.UserLimit,
121-
AuditLog: auditLog,
127+
UserLimit: options.UserLimit,
128+
AuditLog: auditLog,
129+
BrowserOnly: browserOnly,
122130
},
123131
}
124132
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c)

enterprise/coderd/licenses.go

+3-2
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@ var key20220812 []byte
4545
var Keys = map[string]ed25519.PublicKey{"2022-08-12": ed25519.PublicKey(key20220812)}
4646

4747
type Features struct {
48-
UserLimit int64 `json:"user_limit"`
49-
AuditLog int64 `json:"audit_log"`
48+
UserLimit int64 `json:"user_limit"`
49+
AuditLog int64 `json:"audit_log"`
50+
BrowserOnly int64 `json:"browser_only"`
5051
}
5152

5253
type Claims struct {

enterprise/coderd/workspaceagents.go

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package coderd
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/coder/coder/coderd/httpapi"
7+
"github.com/coder/coder/codersdk"
8+
)
9+
10+
func (api *API) shouldBlockNonBrowserConnections(rw http.ResponseWriter) bool {
11+
api.entitlementsMu.Lock()
12+
browserOnly := api.entitlements.browserOnly
13+
api.entitlementsMu.Unlock()
14+
if api.BrowserOnly && browserOnly != codersdk.EntitlementNotEntitled {
15+
httpapi.Write(rw, http.StatusConflict, codersdk.Response{
16+
Message: "Non-browser connections are disabled for your deployment.",
17+
})
18+
return true
19+
}
20+
return false
21+
}

0 commit comments

Comments
 (0)