Skip to content

feat: Add browser-only connections to Enterprise #4135

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Sep 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -489,8 +489,9 @@ func New(options *Options) *API {

type API struct {
*Options
Auditor atomic.Pointer[audit.Auditor]
HTTPAuth *HTTPAuthorizer
Auditor atomic.Pointer[audit.Auditor]
WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool]
HTTPAuth *HTTPAuthorizer

// APIHandler serves "/api/v2"
APIHandler chi.Router
Expand Down
5 changes: 5 additions & 0 deletions coderd/workspaceagents.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,11 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R
httpapi.ResourceNotFound(rw)
return
}
// This is used by Enterprise code to control the functionality of this route.
override := api.WorkspaceClientCoordinateOverride.Load()
if override != nil && (*override)(rw) {
return
}

api.websocketWaitMutex.Lock()
api.websocketWaitGroup.Add(1)
Expand Down
9 changes: 5 additions & 4 deletions codersdk/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ const (
)

const (
FeatureUserLimit = "user_limit"
FeatureAuditLog = "audit_log"
FeatureSCIM = "scim"
FeatureUserLimit = "user_limit"
FeatureAuditLog = "audit_log"
FeatureBrowserOnly = "browser_only"
FeatureSCIM = "scim"
)

var FeatureNames = []string{FeatureUserLimit, FeatureAuditLog, FeatureSCIM}
var FeatureNames = []string{FeatureUserLimit, FeatureAuditLog, FeatureBrowserOnly, FeatureSCIM}

type Feature struct {
Entitlement Entitlement `json:"entitlement"`
Expand Down
24 changes: 22 additions & 2 deletions codersdk/workspaceagents.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,23 +270,37 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg
}
ctx, cancelFunc := context.WithCancel(ctx)
closed := make(chan struct{})
first := make(chan error)
go func() {
defer close(closed)
isFirst := true
for retrier := retry.New(50*time.Millisecond, 10*time.Second); retrier.Wait(ctx); {
logger.Debug(ctx, "connecting")
// nolint:bodyclose
ws, _, err := websocket.Dial(ctx, coordinateURL.String(), &websocket.DialOptions{
ws, res, err := websocket.Dial(ctx, coordinateURL.String(), &websocket.DialOptions{
HTTPClient: httpClient,
// Need to disable compression to avoid a data-race.
CompressionMode: websocket.CompressionDisabled,
})
if errors.Is(err, context.Canceled) {
return
}
if isFirst {
if res.StatusCode == http.StatusConflict {
first <- readBodyAsError(res)
return
}
isFirst = false
close(first)
}
if err != nil {
logger.Debug(ctx, "failed to dial", slog.Error(err))
continue
}
if isFirst {
isFirst = false
close(first)
}
sendNode, errChan := tailnet.ServeCoordinator(websocket.NetConn(ctx, ws, websocket.MessageBinary), func(node []*tailnet.Node) error {
return conn.UpdateNodes(node)
})
Expand All @@ -305,13 +319,19 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg
_ = ws.Close(websocket.StatusAbnormalClosure, "")
}
}()
err = <-first
if err != nil {
cancelFunc()
_ = conn.Close()
return nil, err
}
return &agent.Conn{
Conn: conn,
CloseFunc: func() {
cancelFunc()
<-closed
},
}, nil
}, err
}

// WorkspaceAgent returns an agent by ID.
Expand Down
1 change: 1 addition & 0 deletions docs/admin/enterprise.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Contact sales@coder.com to obtain a license.
These features are:

* Audit Logging
* Browser Only Connections

## Adding your license key

Expand Down
4 changes: 3 additions & 1 deletion enterprise/cli/features_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,14 @@ func TestFeaturesList(t *testing.T) {
var entitlements codersdk.Entitlements
err := json.Unmarshal(buf.Bytes(), &entitlements)
require.NoError(t, err, "unmarshal JSON output")
assert.Len(t, entitlements.Features, 2)
assert.Len(t, entitlements.Features, 3)
assert.Empty(t, entitlements.Warnings)
assert.Equal(t, codersdk.EntitlementNotEntitled,
entitlements.Features[codersdk.FeatureUserLimit].Entitlement)
assert.Equal(t, codersdk.EntitlementNotEntitled,
entitlements.Features[codersdk.FeatureAuditLog].Entitlement)
assert.Equal(t, codersdk.EntitlementNotEntitled,
entitlements.Features[codersdk.FeatureBrowserOnly].Entitlement)
assert.False(t, entitlements.HasLicense)
})
}
12 changes: 10 additions & 2 deletions enterprise/cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/spf13/cobra"

"github.com/coder/coder/cli/cliflag"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/enterprise/coderd"

agpl "github.com/coder/coder/cli"
Expand All @@ -15,11 +16,13 @@ import (
func server() *cobra.Command {
var (
auditLogging bool
browserOnly bool
scimAuthHeader string
)
cmd := agpl.Server(func(ctx context.Context, options *agplcoderd.Options) (*agplcoderd.API, error) {
api, err := coderd.New(ctx, &coderd.Options{
AuditLogging: auditLogging,
BrowserOnly: browserOnly,
SCIMAPIKey: []byte(scimAuthHeader),
Options: options,
})
Expand All @@ -28,9 +31,14 @@ func server() *cobra.Command {
}
return api.AGPL, nil
})
enterpriseOnly := cliui.Styles.Keyword.Render("This is an Enterprise feature. Contact sales@coder.com for licensing")

cliflag.BoolVarP(cmd.Flags(), &auditLogging, "audit-logging", "", "CODER_AUDIT_LOGGING", true,
"Specifies whether audit logging is enabled.")
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.")
"Specifies whether audit logging is enabled. "+enterpriseOnly)
cliflag.BoolVarP(cmd.Flags(), &browserOnly, "browser-only", "", "CODER_BROWSER_ONLY", false,
"Whether Coder only allows connections to workspaces via the browser. "+enterpriseOnly)
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. "+enterpriseOnly)

return cmd
}
33 changes: 28 additions & 5 deletions enterprise/coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ func New(ctx context.Context, options *Options) (*API, error) {
OIDC: options.OIDCConfig,
}
apiKeyMiddleware := httpmw.ExtractAPIKey(options.Database, oauthConfigs, false)

api.AGPL.APIHandler.Group(func(r chi.Router) {
r.Get("/entitlements", api.serveEntitlements)
r.Route("/licenses", func(r chi.Router) {
Expand Down Expand Up @@ -88,7 +87,9 @@ func New(ctx context.Context, options *Options) (*API, error) {
type Options struct {
*coderd.Options

AuditLogging bool
AuditLogging bool
// Whether to block non-browser connections.
BrowserOnly bool
SCIMAPIKey []byte
EntitlementsUpdateInterval time.Duration
Keys map[string]ed25519.PublicKey
Expand All @@ -107,6 +108,7 @@ type entitlements struct {
hasLicense bool
activeUsers codersdk.Feature
auditLogs codersdk.Entitlement
browserOnly codersdk.Entitlement
scim codersdk.Entitlement
}

Expand All @@ -131,8 +133,9 @@ func (api *API) updateEntitlements(ctx context.Context) error {
Enabled: false,
Entitlement: codersdk.EntitlementNotEntitled,
},
auditLogs: codersdk.EntitlementNotEntitled,
scim: codersdk.EntitlementNotEntitled,
auditLogs: codersdk.EntitlementNotEntitled,
scim: codersdk.EntitlementNotEntitled,
browserOnly: codersdk.EntitlementNotEntitled,
}

// Here we loop through licenses to detect enabled features.
Expand Down Expand Up @@ -165,6 +168,9 @@ func (api *API) updateEntitlements(ctx context.Context) error {
if claims.Features.AuditLog > 0 {
entitlements.auditLogs = entitlement
}
if claims.Features.BrowserOnly > 0 {
entitlements.browserOnly = entitlement
}
if claims.Features.SCIM > 0 {
entitlements.scim = entitlement
}
Expand All @@ -174,7 +180,7 @@ func (api *API) updateEntitlements(ctx context.Context) error {
auditor := agplaudit.NewNop()
// A flag could be added to the options that would allow disabling
// enhanced audit logging here!
if entitlements.auditLogs == codersdk.EntitlementEntitled && api.AuditLogging {
if entitlements.auditLogs != codersdk.EntitlementNotEntitled && api.AuditLogging {
auditor = audit.NewAuditor(
audit.DefaultFilter,
backends.NewPostgres(api.Database, true),
Expand All @@ -184,6 +190,14 @@ func (api *API) updateEntitlements(ctx context.Context) error {
api.AGPL.Auditor.Store(&auditor)
}

if entitlements.browserOnly != api.entitlements.browserOnly {
var handler func(rw http.ResponseWriter) bool
if entitlements.browserOnly != codersdk.EntitlementNotEntitled && api.BrowserOnly {
handler = api.shouldBlockNonBrowserConnections
}
api.AGPL.WorkspaceClientCoordinateOverride.Store(&handler)
}

api.entitlements = entitlements

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

resp.Features[codersdk.FeatureBrowserOnly] = codersdk.Feature{
Entitlement: entitlements.browserOnly,
Enabled: api.BrowserOnly,
}
if entitlements.browserOnly == codersdk.EntitlementGracePeriod && api.BrowserOnly {
resp.Warnings = append(resp.Warnings,
"Browser only connections are enabled but your license for this feature is expired.")
}

httpapi.Write(ctx, rw, http.StatusOK, resp)
}

Expand Down
20 changes: 15 additions & 5 deletions enterprise/coderd/coderd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,18 @@ func TestEntitlements(t *testing.T) {
})
t.Run("Warnings", func(t *testing.T) {
t.Parallel()
client := coderdenttest.New(t, nil)
client := coderdenttest.New(t, &coderdenttest.Options{
BrowserOnly: true,
})
first := coderdtest.CreateFirstUser(t, client)
for i := 0; i < 4; i++ {
coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
}
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
UserLimit: 4,
AuditLog: true,
GraceAt: time.Now().Add(-time.Second),
UserLimit: 4,
AuditLog: true,
BrowserOnly: true,
GraceAt: time.Now().Add(-time.Second),
})
res, err := client.Entitlements(context.Background())
require.NoError(t, err)
Expand All @@ -107,11 +110,18 @@ func TestEntitlements(t *testing.T) {
assert.True(t, al.Enabled)
assert.Nil(t, al.Limit)
assert.Nil(t, al.Actual)
assert.Len(t, res.Warnings, 2)
bo := res.Features[codersdk.FeatureBrowserOnly]
assert.Equal(t, codersdk.EntitlementGracePeriod, bo.Entitlement)
assert.True(t, bo.Enabled)
assert.Nil(t, bo.Limit)
assert.Nil(t, bo.Actual)
assert.Len(t, res.Warnings, 3)
assert.Contains(t, res.Warnings,
"Your deployment has 5 active users but is only licensed for 4.")
assert.Contains(t, res.Warnings,
"Audit logging is enabled but your license for this feature is expired.")
assert.Contains(t, res.Warnings,
"Browser only connections are enabled but your license for this feature is expired.")
})
t.Run("Pubsub", func(t *testing.T) {
t.Parallel()
Expand Down
14 changes: 11 additions & 3 deletions enterprise/coderd/coderdenttest/coderdenttest.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func init() {

type Options struct {
*coderdtest.Options
BrowserOnly bool
EntitlementsUpdateInterval time.Duration
SCIMAPIKey []byte
}
Expand All @@ -56,6 +57,7 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c
srv, cancelFunc, oop := coderdtest.NewOptions(t, options.Options)
coderAPI, err := coderd.New(context.Background(), &coderd.Options{
AuditLogging: true,
BrowserOnly: options.BrowserOnly,
SCIMAPIKey: options.SCIMAPIKey,
Options: oop,
EntitlementsUpdateInterval: options.EntitlementsUpdateInterval,
Expand Down Expand Up @@ -84,6 +86,7 @@ type LicenseOptions struct {
ExpiresAt time.Time
UserLimit int64
AuditLog bool
BrowserOnly bool
SCIM bool
}

Expand All @@ -108,6 +111,10 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
if options.AuditLog {
auditLog = 1
}
browserOnly := int64(0)
if options.BrowserOnly {
browserOnly = 1
}
scim := int64(0)
if options.SCIM {
scim = 1
Expand All @@ -125,9 +132,10 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
AccountID: options.AccountID,
Version: coderd.CurrentVersion,
Features: coderd.Features{
UserLimit: options.UserLimit,
AuditLog: auditLog,
SCIM: scim,
UserLimit: options.UserLimit,
AuditLog: auditLog,
BrowserOnly: browserOnly,
SCIM: scim,
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c)
Expand Down
7 changes: 4 additions & 3 deletions enterprise/coderd/licenses.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,10 @@ var key20220812 []byte
var Keys = map[string]ed25519.PublicKey{"2022-08-12": ed25519.PublicKey(key20220812)}

type Features struct {
UserLimit int64 `json:"user_limit"`
AuditLog int64 `json:"audit_log"`
SCIM int64 `json:"scim"`
UserLimit int64 `json:"user_limit"`
AuditLog int64 `json:"audit_log"`
BrowserOnly int64 `json:"browser_only"`
SCIM int64 `json:"scim"`
}

type Claims struct {
Expand Down
Loading