feat(coderd): add webpush package (#17091)

* Adds `codersdk.ExperimentWebPush` (`web-push`)
* Adds a `coderd/webpush` package that allows sending native push
notifications via `github.com/SherClockHolmes/webpush-go`
* Adds database tables to store push notification subscriptions.
* Adds an API endpoint that allows users to subscribe/unsubscribe, and
send a test notification (404 without experiment, excluded from API docs)
* Adds server CLI command to regenerate VAPID keys (note: regenerating
the VAPID keypair requires deleting all existing subscriptions)

---------

Co-authored-by: Kyle Carberry <kyle@carberry.com>
This commit is contained in:
Cian Johnston
2025-03-27 10:03:53 +00:00
committed by GitHub
co-authored by Kyle Carberry
parent 006600ea3e
commit 06e5d9ef21
43 changed files with 2136 additions and 20 deletions
+23 -1
View File
@@ -64,6 +64,7 @@ import (
"github.com/coder/coder/v2/coderd/entitlements"
"github.com/coder/coder/v2/coderd/notifications/reports"
"github.com/coder/coder/v2/coderd/runtimeconfig"
"github.com/coder/coder/v2/coderd/webpush"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/clilog"
@@ -775,6 +776,26 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
return xerrors.Errorf("set deployment id: %w", err)
}
// Manage push notifications.
experiments := coderd.ReadExperiments(options.Logger, options.DeploymentValues.Experiments.Value())
if experiments.Enabled(codersdk.ExperimentWebPush) {
webpusher, err := webpush.New(ctx, &options.Logger, options.Database)
if err != nil {
options.Logger.Error(ctx, "failed to create web push dispatcher", slog.Error(err))
options.Logger.Warn(ctx, "web push notifications will not work until the VAPID keys are regenerated")
webpusher = &webpush.NoopWebpusher{
Msg: "Web Push notifications are disabled due to a system error. Please contact your Coder administrator.",
}
}
options.WebPushDispatcher = webpusher
} else {
options.WebPushDispatcher = &webpush.NoopWebpusher{
// Users will likely not see this message as the endpoints return 404
// if not enabled. Just in case...
Msg: "Web Push notifications are an experimental feature and are disabled by default. Enable the 'web-push' experiment to use this feature.",
}
}
githubOAuth2ConfigParams, err := getGithubOAuth2ConfigParams(ctx, options.Database, vals)
if err != nil {
return xerrors.Errorf("get github oauth2 config params: %w", err)
@@ -1255,6 +1276,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
}
createAdminUserCmd := r.newCreateAdminUserCommand()
regenerateVapidKeypairCmd := r.newRegenerateVapidKeypairCommand()
rawURLOpt := serpent.Option{
Flag: "raw-url",
@@ -1268,7 +1290,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
serverCmd.Children = append(
serverCmd.Children,
createAdminUserCmd, postgresBuiltinURLCmd, postgresBuiltinServeCmd,
createAdminUserCmd, postgresBuiltinURLCmd, postgresBuiltinServeCmd, regenerateVapidKeypairCmd,
)
return serverCmd
+112
View File
@@ -0,0 +1,112 @@
//go:build !slim
package cli
import (
"fmt"
"golang.org/x/xerrors"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/awsiamrds"
"github.com/coder/coder/v2/coderd/webpush"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func (r *RootCmd) newRegenerateVapidKeypairCommand() *serpent.Command {
var (
regenVapidKeypairDBURL string
regenVapidKeypairPgAuth string
)
regenerateVapidKeypairCommand := &serpent.Command{
Use: "regenerate-vapid-keypair",
Short: "Regenerate the VAPID keypair used for web push notifications.",
Hidden: true, // Hide this command as it's an experimental feature
Handler: func(inv *serpent.Invocation) error {
var (
ctx, cancel = inv.SignalNotifyContext(inv.Context(), StopSignals...)
cfg = r.createConfig()
logger = inv.Logger.AppendSinks(sloghuman.Sink(inv.Stderr))
)
if r.verbose {
logger = logger.Leveled(slog.LevelDebug)
}
defer cancel()
if regenVapidKeypairDBURL == "" {
cliui.Infof(inv.Stdout, "Using built-in PostgreSQL (%s)", cfg.PostgresPath())
url, closePg, err := startBuiltinPostgres(ctx, cfg, logger, "")
if err != nil {
return err
}
defer func() {
_ = closePg()
}()
regenVapidKeypairDBURL = url
}
sqlDriver := "postgres"
var err error
if codersdk.PostgresAuth(regenVapidKeypairPgAuth) == codersdk.PostgresAuthAWSIAMRDS {
sqlDriver, err = awsiamrds.Register(inv.Context(), sqlDriver)
if err != nil {
return xerrors.Errorf("register aws rds iam auth: %w", err)
}
}
sqlDB, err := ConnectToPostgres(ctx, logger, sqlDriver, regenVapidKeypairDBURL, nil)
if err != nil {
return xerrors.Errorf("connect to postgres: %w", err)
}
defer func() {
_ = sqlDB.Close()
}()
db := database.New(sqlDB)
// Confirm that the user really wants to regenerate the VAPID keypair.
cliui.Infof(inv.Stdout, "Regenerating VAPID keypair...")
cliui.Infof(inv.Stdout, "This will delete all existing webpush subscriptions.")
cliui.Infof(inv.Stdout, "Are you sure you want to continue? (y/N)")
if resp, err := cliui.Prompt(inv, cliui.PromptOptions{
IsConfirm: true,
Default: cliui.ConfirmNo,
}); err != nil || resp != cliui.ConfirmYes {
return xerrors.Errorf("VAPID keypair regeneration failed: %w", err)
}
if _, _, err := webpush.RegenerateVAPIDKeys(ctx, db); err != nil {
return xerrors.Errorf("regenerate vapid keypair: %w", err)
}
_, _ = fmt.Fprintln(inv.Stdout, "VAPID keypair regenerated successfully.")
return nil
},
}
regenerateVapidKeypairCommand.Options.Add(
cliui.SkipPromptOption(),
serpent.Option{
Env: "CODER_PG_CONNECTION_URL",
Flag: "postgres-url",
Description: "URL of a PostgreSQL database. If empty, the built-in PostgreSQL deployment will be used (Coder must not be already running in this case).",
Value: serpent.StringOf(&regenVapidKeypairDBURL),
},
serpent.Option{
Name: "Postgres Connection Auth",
Description: "Type of auth to use when connecting to postgres.",
Flag: "postgres-connection-auth",
Env: "CODER_PG_CONNECTION_AUTH",
Default: "password",
Value: serpent.EnumOf(&regenVapidKeypairPgAuth, codersdk.PostgresAuthDrivers...),
},
)
return regenerateVapidKeypairCommand
}
+118
View File
@@ -0,0 +1,118 @@
package cli_test
import (
"context"
"database/sql"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
)
func TestRegenerateVapidKeypair(t *testing.T) {
t.Parallel()
if !dbtestutil.WillUsePostgres() {
t.Skip("this test is only supported on postgres")
}
t.Run("NoExistingVAPIDKeys", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
t.Cleanup(cancel)
connectionURL, err := dbtestutil.Open(t)
require.NoError(t, err)
sqlDB, err := sql.Open("postgres", connectionURL)
require.NoError(t, err)
defer sqlDB.Close()
db := database.New(sqlDB)
// Ensure there is no existing VAPID keypair.
rows, err := db.GetWebpushVAPIDKeys(ctx)
require.NoError(t, err)
require.Empty(t, rows)
inv, _ := clitest.New(t, "server", "regenerate-vapid-keypair", "--postgres-url", connectionURL, "--yes")
pty := ptytest.New(t)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
clitest.Start(t, inv)
pty.ExpectMatchContext(ctx, "Regenerating VAPID keypair...")
pty.ExpectMatchContext(ctx, "This will delete all existing webpush subscriptions.")
pty.ExpectMatchContext(ctx, "Are you sure you want to continue? (y/N)")
pty.WriteLine("y")
pty.ExpectMatchContext(ctx, "VAPID keypair regenerated successfully.")
// Ensure the VAPID keypair was created.
keys, err := db.GetWebpushVAPIDKeys(ctx)
require.NoError(t, err)
require.NotEmpty(t, keys.VapidPublicKey)
require.NotEmpty(t, keys.VapidPrivateKey)
})
t.Run("ExistingVAPIDKeys", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
t.Cleanup(cancel)
connectionURL, err := dbtestutil.Open(t)
require.NoError(t, err)
sqlDB, err := sql.Open("postgres", connectionURL)
require.NoError(t, err)
defer sqlDB.Close()
db := database.New(sqlDB)
for i := 0; i < 10; i++ {
// Insert a few fake users.
u := dbgen.User(t, db, database.User{})
// Insert a few fake push subscriptions for each user.
for j := 0; j < 10; j++ {
_ = dbgen.WebpushSubscription(t, db, database.InsertWebpushSubscriptionParams{
UserID: u.ID,
})
}
}
inv, _ := clitest.New(t, "server", "regenerate-vapid-keypair", "--postgres-url", connectionURL, "--yes")
pty := ptytest.New(t)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
clitest.Start(t, inv)
pty.ExpectMatchContext(ctx, "Regenerating VAPID keypair...")
pty.ExpectMatchContext(ctx, "This will delete all existing webpush subscriptions.")
pty.ExpectMatchContext(ctx, "Are you sure you want to continue? (y/N)")
pty.WriteLine("y")
pty.ExpectMatchContext(ctx, "VAPID keypair regenerated successfully.")
// Ensure the VAPID keypair was created.
keys, err := db.GetWebpushVAPIDKeys(ctx)
require.NoError(t, err)
require.NotEmpty(t, keys.VapidPublicKey)
require.NotEmpty(t, keys.VapidPrivateKey)
// Ensure the push subscriptions were deleted.
var count int64
rows, err := sqlDB.QueryContext(ctx, "SELECT COUNT(*) FROM webpush_subscriptions")
require.NoError(t, err)
t.Cleanup(func() {
_ = rows.Close()
})
require.True(t, rows.Next())
require.NoError(t, rows.Scan(&count))
require.Equal(t, int64(0), count)
})
}
+6 -6
View File
@@ -6,12 +6,12 @@ USAGE:
Start a Coder server
SUBCOMMANDS:
create-admin-user Create a new admin user with the given username,
email and password and adds it to every
organization.
postgres-builtin-serve Run the built-in PostgreSQL deployment.
postgres-builtin-url Output the connection URL for the built-in
PostgreSQL deployment.
create-admin-user Create a new admin user with the given username,
email and password and adds it to every
organization.
postgres-builtin-serve Run the built-in PostgreSQL deployment.
postgres-builtin-url Output the connection URL for the built-in
PostgreSQL deployment.
OPTIONS:
--allow-workspace-renames bool, $CODER_ALLOW_WORKSPACE_RENAMES (default: false)