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

Skip to content

Commit 7ad4276

Browse files
authored
feat: Add browser-only connections to Enterprise (#4135)
* feat: Add browser-only connections to Enterprise Fixes #4131. * Fix formatting
1 parent 656dcc0 commit 7ad4276

File tree

19 files changed

+263
-41
lines changed

19 files changed

+263
-41
lines changed

coderd/coderd.go

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

490490
type API struct {
491491
*Options
492-
Auditor atomic.Pointer[audit.Auditor]
493-
HTTPAuth *HTTPAuthorizer
492+
Auditor atomic.Pointer[audit.Auditor]
493+
WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool]
494+
HTTPAuth *HTTPAuthorizer
494495

495496
// APIHandler serves "/api/v2"
496497
APIHandler chi.Router

coderd/workspaceagents.go

+5
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,11 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R
403403
httpapi.ResourceNotFound(rw)
404404
return
405405
}
406+
// This is used by Enterprise code to control the functionality of this route.
407+
override := api.WorkspaceClientCoordinateOverride.Load()
408+
if override != nil && (*override)(rw) {
409+
return
410+
}
406411

407412
api.websocketWaitMutex.Lock()
408413
api.websocketWaitGroup.Add(1)

codersdk/features.go

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

1717
const (
18-
FeatureUserLimit = "user_limit"
19-
FeatureAuditLog = "audit_log"
20-
FeatureSCIM = "scim"
18+
FeatureUserLimit = "user_limit"
19+
FeatureAuditLog = "audit_log"
20+
FeatureBrowserOnly = "browser_only"
21+
FeatureSCIM = "scim"
2122
)
2223

23-
var FeatureNames = []string{FeatureUserLimit, FeatureAuditLog, FeatureSCIM}
24+
var FeatureNames = []string{FeatureUserLimit, FeatureAuditLog, FeatureBrowserOnly, FeatureSCIM}
2425

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

codersdk/workspaceagents.go

+22-2
Original file line numberDiff line numberDiff line change
@@ -270,23 +270,37 @@ 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,
282284
})
283285
if errors.Is(err, context.Canceled) {
284286
return
285287
}
288+
if isFirst {
289+
if res.StatusCode == http.StatusConflict {
290+
first <- readBodyAsError(res)
291+
return
292+
}
293+
isFirst = false
294+
close(first)
295+
}
286296
if err != nil {
287297
logger.Debug(ctx, "failed to dial", slog.Error(err))
288298
continue
289299
}
300+
if isFirst {
301+
isFirst = false
302+
close(first)
303+
}
290304
sendNode, errChan := tailnet.ServeCoordinator(websocket.NetConn(ctx, ws, websocket.MessageBinary), func(node []*tailnet.Node) error {
291305
return conn.UpdateNodes(node)
292306
})
@@ -305,13 +319,19 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg
305319
_ = ws.Close(websocket.StatusAbnormalClosure, "")
306320
}
307321
}()
322+
err = <-first
323+
if err != nil {
324+
cancelFunc()
325+
_ = conn.Close()
326+
return nil, err
327+
}
308328
return &agent.Conn{
309329
Conn: conn,
310330
CloseFunc: func() {
311331
cancelFunc()
312332
<-closed
313333
},
314-
}, nil
334+
}, err
315335
}
316336

317337
// 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/features_test.go

+3-1
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,14 @@ func TestFeaturesList(t *testing.T) {
5757
var entitlements codersdk.Entitlements
5858
err := json.Unmarshal(buf.Bytes(), &entitlements)
5959
require.NoError(t, err, "unmarshal JSON output")
60-
assert.Len(t, entitlements.Features, 2)
60+
assert.Len(t, entitlements.Features, 3)
6161
assert.Empty(t, entitlements.Warnings)
6262
assert.Equal(t, codersdk.EntitlementNotEntitled,
6363
entitlements.Features[codersdk.FeatureUserLimit].Entitlement)
6464
assert.Equal(t, codersdk.EntitlementNotEntitled,
6565
entitlements.Features[codersdk.FeatureAuditLog].Entitlement)
66+
assert.Equal(t, codersdk.EntitlementNotEntitled,
67+
entitlements.Features[codersdk.FeatureBrowserOnly].Entitlement)
6668
assert.False(t, entitlements.HasLicense)
6769
})
6870
}

enterprise/cli/server.go

+10-2
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,11 +16,13 @@ import (
1516
func server() *cobra.Command {
1617
var (
1718
auditLogging bool
19+
browserOnly bool
1820
scimAuthHeader string
1921
)
2022
cmd := agpl.Server(func(ctx context.Context, options *agplcoderd.Options) (*agplcoderd.API, error) {
2123
api, err := coderd.New(ctx, &coderd.Options{
2224
AuditLogging: auditLogging,
25+
BrowserOnly: browserOnly,
2326
SCIMAPIKey: []byte(scimAuthHeader),
2427
Options: options,
2528
})
@@ -28,9 +31,14 @@ func server() *cobra.Command {
2831
}
2932
return api.AGPL, nil
3033
})
34+
enterpriseOnly := cliui.Styles.Keyword.Render("This is an Enterprise feature. Contact [email protected] for licensing")
35+
3136
cliflag.BoolVarP(cmd.Flags(), &auditLogging, "audit-logging", "", "CODER_AUDIT_LOGGING", true,
32-
"Specifies whether audit logging is enabled.")
33-
cliflag.StringVarP(cmd.Flags(), &scimAuthHeader, "scim-auth-header", "", "CODER_SCIM_API_KEY", "", "Enables SCIM and sets the authentication header for the built-in SCIM server. New users are automatically created with OIDC authentication.")
37+
"Specifies whether audit logging is enabled. "+enterpriseOnly)
38+
cliflag.BoolVarP(cmd.Flags(), &browserOnly, "browser-only", "", "CODER_BROWSER_ONLY", false,
39+
"Whether Coder only allows connections to workspaces via the browser. "+enterpriseOnly)
40+
cliflag.StringVarP(cmd.Flags(), &scimAuthHeader, "scim-auth-header", "", "CODER_SCIM_API_KEY", "",
41+
"Enables SCIM and sets the authentication header for the built-in SCIM server. New users are automatically created with OIDC authentication. "+enterpriseOnly)
3442

3543
return cmd
3644
}

enterprise/coderd/coderd.go

+28-5
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) {
@@ -88,7 +87,9 @@ func New(ctx context.Context, options *Options) (*API, error) {
8887
type Options struct {
8988
*coderd.Options
9089

91-
AuditLogging bool
90+
AuditLogging bool
91+
// Whether to block non-browser connections.
92+
BrowserOnly bool
9293
SCIMAPIKey []byte
9394
EntitlementsUpdateInterval time.Duration
9495
Keys map[string]ed25519.PublicKey
@@ -107,6 +108,7 @@ type entitlements struct {
107108
hasLicense bool
108109
activeUsers codersdk.Feature
109110
auditLogs codersdk.Entitlement
111+
browserOnly codersdk.Entitlement
110112
scim codersdk.Entitlement
111113
}
112114

@@ -131,8 +133,9 @@ func (api *API) updateEntitlements(ctx context.Context) error {
131133
Enabled: false,
132134
Entitlement: codersdk.EntitlementNotEntitled,
133135
},
134-
auditLogs: codersdk.EntitlementNotEntitled,
135-
scim: codersdk.EntitlementNotEntitled,
136+
auditLogs: codersdk.EntitlementNotEntitled,
137+
scim: codersdk.EntitlementNotEntitled,
138+
browserOnly: codersdk.EntitlementNotEntitled,
136139
}
137140

138141
// Here we loop through licenses to detect enabled features.
@@ -165,6 +168,9 @@ func (api *API) updateEntitlements(ctx context.Context) error {
165168
if claims.Features.AuditLog > 0 {
166169
entitlements.auditLogs = entitlement
167170
}
171+
if claims.Features.BrowserOnly > 0 {
172+
entitlements.browserOnly = entitlement
173+
}
168174
if claims.Features.SCIM > 0 {
169175
entitlements.scim = entitlement
170176
}
@@ -174,7 +180,7 @@ func (api *API) updateEntitlements(ctx context.Context) error {
174180
auditor := agplaudit.NewNop()
175181
// A flag could be added to the options that would allow disabling
176182
// enhanced audit logging here!
177-
if entitlements.auditLogs == codersdk.EntitlementEntitled && api.AuditLogging {
183+
if entitlements.auditLogs != codersdk.EntitlementNotEntitled && api.AuditLogging {
178184
auditor = audit.NewAuditor(
179185
audit.DefaultFilter,
180186
backends.NewPostgres(api.Database, true),
@@ -184,6 +190,14 @@ func (api *API) updateEntitlements(ctx context.Context) error {
184190
api.AGPL.Auditor.Store(&auditor)
185191
}
186192

193+
if entitlements.browserOnly != api.entitlements.browserOnly {
194+
var handler func(rw http.ResponseWriter) bool
195+
if entitlements.browserOnly != codersdk.EntitlementNotEntitled && api.BrowserOnly {
196+
handler = api.shouldBlockNonBrowserConnections
197+
}
198+
api.AGPL.WorkspaceClientCoordinateOverride.Store(&handler)
199+
}
200+
187201
api.entitlements = entitlements
188202

189203
return nil
@@ -230,6 +244,15 @@ func (api *API) serveEntitlements(rw http.ResponseWriter, r *http.Request) {
230244
"Audit logging is enabled but your license for this feature is expired.")
231245
}
232246

247+
resp.Features[codersdk.FeatureBrowserOnly] = codersdk.Feature{
248+
Entitlement: entitlements.browserOnly,
249+
Enabled: api.BrowserOnly,
250+
}
251+
if entitlements.browserOnly == codersdk.EntitlementGracePeriod && api.BrowserOnly {
252+
resp.Warnings = append(resp.Warnings,
253+
"Browser only connections are enabled but your license for this feature is expired.")
254+
}
255+
233256
httpapi.Write(ctx, rw, http.StatusOK, resp)
234257
}
235258

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

+11-3
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
SCIMAPIKey []byte
4142
}
@@ -56,6 +57,7 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c
5657
srv, cancelFunc, oop := coderdtest.NewOptions(t, options.Options)
5758
coderAPI, err := coderd.New(context.Background(), &coderd.Options{
5859
AuditLogging: true,
60+
BrowserOnly: options.BrowserOnly,
5961
SCIMAPIKey: options.SCIMAPIKey,
6062
Options: oop,
6163
EntitlementsUpdateInterval: options.EntitlementsUpdateInterval,
@@ -84,6 +86,7 @@ type LicenseOptions struct {
8486
ExpiresAt time.Time
8587
UserLimit int64
8688
AuditLog bool
89+
BrowserOnly bool
8790
SCIM bool
8891
}
8992

@@ -108,6 +111,10 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
108111
if options.AuditLog {
109112
auditLog = 1
110113
}
114+
browserOnly := int64(0)
115+
if options.BrowserOnly {
116+
browserOnly = 1
117+
}
111118
scim := int64(0)
112119
if options.SCIM {
113120
scim = 1
@@ -125,9 +132,10 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
125132
AccountID: options.AccountID,
126133
Version: coderd.CurrentVersion,
127134
Features: coderd.Features{
128-
UserLimit: options.UserLimit,
129-
AuditLog: auditLog,
130-
SCIM: scim,
135+
UserLimit: options.UserLimit,
136+
AuditLog: auditLog,
137+
BrowserOnly: browserOnly,
138+
SCIM: scim,
131139
},
132140
}
133141
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c)

enterprise/coderd/licenses.go

+4-3
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,10 @@ 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"`
50-
SCIM int64 `json:"scim"`
48+
UserLimit int64 `json:"user_limit"`
49+
AuditLog int64 `json:"audit_log"`
50+
BrowserOnly int64 `json:"browser_only"`
51+
SCIM int64 `json:"scim"`
5152
}
5253

5354
type Claims struct {

0 commit comments

Comments
 (0)