diff --git a/.github/workflows/mlc_config.json b/.github/workflows/mlc_config.json index a1d735be9479c..f26a02a72ea2c 100644 --- a/.github/workflows/mlc_config.json +++ b/.github/workflows/mlc_config.json @@ -17,6 +17,9 @@ }, { "pattern": "tailscale.com" + }, + { + "pattern": "wireguard.com" } ], "aliveStatusCodes": [200, 0] diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a87ee28027f3e..48c68ca9e5601 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8541,6 +8541,10 @@ const docTemplate = `{ "description": "DashboardURL is the URL to hit the deployment's dashboard.\nFor external workspace proxies, this is the coderd they are connected\nto.", "type": "string" }, + "deployment_id": { + "description": "DeploymentID is the unique identifier for this deployment.", + "type": "string" + }, "external_url": { "description": "ExternalURL references the current Coder version.\nFor production builds, this will link directly to a release. For development builds, this will link to a commit.", "type": "string" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7d03086d55997..5270c54eae154 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7599,6 +7599,10 @@ "description": "DashboardURL is the URL to hit the deployment's dashboard.\nFor external workspace proxies, this is the coderd they are connected\nto.", "type": "string" }, + "deployment_id": { + "description": "DeploymentID is the unique identifier for this deployment.", + "type": "string" + }, "external_url": { "description": "ExternalURL references the current Coder version.\nFor production builds, this will link directly to a release. For development builds, this will link to a commit.", "type": "string" diff --git a/coderd/coderd.go b/coderd/coderd.go index 67b16e9032bfe..6c2f08fcc4f98 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -735,7 +735,7 @@ func New(options *Options) *API { // All CSP errors will be logged r.Post("/csp/reports", api.logReportCSPViolations) - r.Get("/buildinfo", buildInfo(api.AccessURL, api.DeploymentValues.CLIUpgradeMessage.String())) + r.Get("/buildinfo", buildInfo(api.AccessURL, api.DeploymentValues.CLIUpgradeMessage.String(), api.DeploymentID)) // /regions is overridden in the enterprise version r.Group(func(r chi.Router) { r.Use(apiKeyMiddleware) diff --git a/coderd/deployment.go b/coderd/deployment.go index 22bd555b28f45..7c39c7ea28ce9 100644 --- a/coderd/deployment.go +++ b/coderd/deployment.go @@ -68,7 +68,7 @@ func (api *API) deploymentStats(rw http.ResponseWriter, r *http.Request) { // @Tags General // @Success 200 {object} codersdk.BuildInfoResponse // @Router /buildinfo [get] -func buildInfo(accessURL *url.URL, upgradeMessage string) http.HandlerFunc { +func buildInfo(accessURL *url.URL, upgradeMessage, deploymentID string) http.HandlerFunc { return func(rw http.ResponseWriter, r *http.Request) { httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.BuildInfoResponse{ ExternalURL: buildinfo.ExternalURL(), @@ -77,6 +77,7 @@ func buildInfo(accessURL *url.URL, upgradeMessage string) http.HandlerFunc { DashboardURL: accessURL.String(), WorkspaceProxy: false, UpgradeMessage: upgradeMessage, + DeploymentID: deploymentID, }) } } diff --git a/codersdk/deployment.go b/codersdk/deployment.go index d2da0c3183333..6d6ea2197a574 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -2151,6 +2151,9 @@ type BuildInfoResponse struct { // UpgradeMessage is the message displayed to users when an outdated client // is detected. UpgradeMessage string `json:"upgrade_message"` + + // DeploymentID is the unique identifier for this deployment. + DeploymentID string `json:"deployment_id"` } type WorkspaceProxyBuildInfo struct { diff --git a/docs/api/general.md b/docs/api/general.md index 95b3933a9a89a..02399a6e3e794 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -55,6 +55,7 @@ curl -X GET http://coder-server:8080/api/v2/buildinfo \ { "agent_api_version": "string", "dashboard_url": "string", + "deployment_id": "string", "external_url": "string", "upgrade_message": "string", "version": "string", diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 9f2a74752a156..90343e1a19a20 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1178,6 +1178,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in { "agent_api_version": "string", "dashboard_url": "string", + "deployment_id": "string", "external_url": "string", "upgrade_message": "string", "version": "string", @@ -1191,6 +1192,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | ------------------- | ------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `agent_api_version` | string | false | | Agent api version is the current version of the Agent API (back versions MAY still be supported). | | `dashboard_url` | string | false | | Dashboard URL is the URL to hit the deployment's dashboard. For external workspace proxies, this is the coderd they are connected to. | +| `deployment_id` | string | false | | Deployment ID is the unique identifier for this deployment. | | `external_url` | string | false | | External URL references the current Coder version. For production builds, this will link directly to a release. For development builds, this will link to a commit. | | `upgrade_message` | string | false | | Upgrade message is the message displayed to users when an outdated client is detected. | | `version` | string | false | | Version returns the semantic version of the build. | diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 22ca40a391227..882c675213546 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -147,13 +147,14 @@ func NewWithAPI(t *testing.T, options *Options) ( } type LicenseOptions struct { - AccountType string - AccountID string - Trial bool - AllFeatures bool - GraceAt time.Time - ExpiresAt time.Time - Features license.Features + AccountType string + AccountID string + DeploymentIDs []string + Trial bool + AllFeatures bool + GraceAt time.Time + ExpiresAt time.Time + Features license.Features } // AddFullLicense generates a license with all features enabled. @@ -190,6 +191,7 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { LicenseExpires: jwt.NewNumericDate(options.GraceAt), AccountType: options.AccountType, AccountID: options.AccountID, + DeploymentIDs: options.DeploymentIDs, Trial: options.Trial, Version: license.CurrentVersion, AllFeatures: options.AllFeatures, diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index a8c10f0c4571e..e5ce3d203b100 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -257,14 +257,16 @@ type Claims struct { // the end of the grace period (identical to LicenseExpires if there is no grace period). // The reason we use the standard claim for the end of the grace period is that we want JWT // processing libraries to consider the token "valid" until then. - LicenseExpires *jwt.NumericDate `json:"license_expires,omitempty"` - AccountType string `json:"account_type,omitempty"` - AccountID string `json:"account_id,omitempty"` - Trial bool `json:"trial"` - AllFeatures bool `json:"all_features"` - Version uint64 `json:"version"` - Features Features `json:"features"` - RequireTelemetry bool `json:"require_telemetry,omitempty"` + LicenseExpires *jwt.NumericDate `json:"license_expires,omitempty"` + AccountType string `json:"account_type,omitempty"` + AccountID string `json:"account_id,omitempty"` + // DeploymentIDs enforces the license can only be used on a set of deployments. + DeploymentIDs []string `json:"deployment_ids,omitempty"` + Trial bool `json:"trial"` + AllFeatures bool `json:"all_features"` + Version uint64 `json:"version"` + Features Features `json:"features"` + RequireTelemetry bool `json:"require_telemetry,omitempty"` } // ParseRaw consumes a license and returns the claims. diff --git a/enterprise/coderd/licenses.go b/enterprise/coderd/licenses.go index 9c32268b39165..2808bc4920e93 100644 --- a/enterprise/coderd/licenses.go +++ b/enterprise/coderd/licenses.go @@ -10,6 +10,7 @@ import ( "encoding/json" "fmt" "net/http" + "slices" "strconv" "strings" "time" @@ -120,6 +121,15 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) { // old licenses with a uuid. id = uuid.New() } + if len(claims.DeploymentIDs) > 0 && !slices.Contains(claims.DeploymentIDs, api.AGPL.DeploymentID) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "License cannot be used on this deployment!", + Detail: fmt.Sprintf("The provided license is locked to the following deployments: %q. "+ + "Your deployment identifier is %q. Please contact sales.", claims.DeploymentIDs, api.AGPL.DeploymentID), + }) + return + } + dl, err := api.Database.InsertLicense(ctx, database.InsertLicenseParams{ UploadedAt: dbtime.Now(), JWT: addLicense.License, diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go index e6f27717e2a11..c2f7d83fbbd6b 100644 --- a/enterprise/coderd/licenses_test.go +++ b/enterprise/coderd/licenses_test.go @@ -5,6 +5,7 @@ import ( "net/http" "testing" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/xerrors" @@ -36,6 +37,22 @@ func TestPostLicense(t *testing.T) { assert.EqualValues(t, 1, features[codersdk.FeatureAuditLog]) }) + t.Run("InvalidDeploymentID", func(t *testing.T) { + t.Parallel() + // The generated deployment will start out with a different deployment ID. + client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true}) + license := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + DeploymentIDs: []string{uuid.NewString()}, + }) + _, err := client.AddLicense(context.Background(), codersdk.AddLicenseRequest{ + License: license, + }) + errResp := &codersdk.Error{} + require.ErrorAs(t, err, &errResp) + require.Equal(t, http.StatusBadRequest, errResp.StatusCode()) + require.Contains(t, errResp.Message, "License cannot be used on this deployment!") + }) + t.Run("Unauthorized", func(t *testing.T) { t.Parallel() client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true}) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 45062e910fa64..af1635ccce05b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -165,6 +165,7 @@ export interface BuildInfoResponse { readonly workspace_proxy: boolean; readonly agent_api_version: string; readonly upgrade_message: string; + readonly deployment_id: string; } // From codersdk/insights.go diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx index 4cce813939ffc..fa3f64c37cb18 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx @@ -12,9 +12,11 @@ import LaunchIcon from "@mui/icons-material/LaunchOutlined"; import DocsIcon from "@mui/icons-material/MenuBook"; import Divider from "@mui/material/Divider"; import MenuItem from "@mui/material/MenuItem"; +import Tooltip from "@mui/material/Tooltip"; import type { FC } from "react"; import { Link } from "react-router-dom"; import type * as TypesGen from "api/typesGenerated"; +import { CopyButton } from "components/CopyButton/CopyButton"; import { ExternalImage } from "components/ExternalImage/ExternalImage"; import { usePopover } from "components/Popover/Popover"; import { Stack } from "components/Stack/Stack"; @@ -161,15 +163,51 @@ export const UserDropdownContent: FC = ({ - - {buildInfo?.version} - + + + {buildInfo?.version} + + + + {Boolean(buildInfo?.deployment_id) && ( +
+ +
+ {buildInfo?.deployment_id} +
+
+ +
+ )}
{Language.copyrightText}
diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 6604faf96eafd..17bb48c1d00bd 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -201,6 +201,7 @@ export const MockBuildInfo: TypesGen.BuildInfoResponse = { dashboard_url: "https:///mock-url", workspace_proxy: false, upgrade_message: "My custom upgrade message", + deployment_id: "510d407f-e521-4180-b559-eab4a6d802b8", }; export const MockSupportLinks: TypesGen.LinkConfig[] = [