Skip to content

Commit

Permalink
cli/demo: enable reuse of web sessions across restarts
Browse files Browse the repository at this point in the history
Prior to this change, every time the `cockroach demo` command would be
stopped, all the web sessions already open would be lost. As a result,
any web browser currently open on the `demo` cluster would switch over
to the login page again.

This was inconvenient because often folk use `cockroach demo` to
iterate on new feature development. They want the ability to keep the
web browser open (and preserve whatever they were looking at) across
restarts of the `cockroach demo` command.

This UX inconvenience was excessively pushing users to use the
`--insecure` flag, which we want to discourage.

So this patch resolves the inconvenience by preserving open web
sessions across restarts of the `demo` session.

Release note (cli change): `cockroach demo` is now able to preserve
open web sessions across restarts of the `cockroach demo` command. The
sessions are saved in the directory `~/.cockroach-demo` alongside the
TLS certificates and keys.
  • Loading branch information
knz committed Jan 6, 2023
1 parent 642afd6 commit f11f231
Show file tree
Hide file tree
Showing 5 changed files with 290 additions and 0 deletions.
3 changes: 3 additions & 0 deletions pkg/cli/demo.go
Original file line number Diff line number Diff line change
Expand Up @@ -375,5 +375,8 @@ func runDemoInternal(
defer c.SetSimulatedLatency(false /* on */)
}

// Ensure the last few entries in the log files are flushed at the end.
defer log.Flush()

return sqlCtx.Run(ctx, conn)
}
2 changes: 2 additions & 0 deletions pkg/cli/democluster/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ go_library(
"demo_cluster.go",
"demo_locality_list.go",
"doc.go",
"session_persistence.go",
"socket_unix.go",
"socket_windows.go",
],
Expand Down Expand Up @@ -53,6 +54,7 @@ go_library(
"@com_github_cockroachdb_errors//:errors",
"@com_github_cockroachdb_errors//oserror",
"@com_github_cockroachdb_logtags//:logtags",
"@com_github_cockroachdb_redact//:redact",
"@com_github_nightlyone_lockfile//:lockfile",
"@org_golang_x_time//rate",
],
Expand Down
15 changes: 15 additions & 0 deletions pkg/cli/democluster/demo_cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,18 @@ func (c *transientCluster) Start(ctx context.Context) (err error) {
}(phaseCtx); err != nil {
return err
}

// Step 10: restore web sessions.
phaseCtx = logtags.AddTag(ctx, "phase", 10)
if err := func(ctx context.Context) error {
if err := c.restoreWebSessions(ctx); err != nil {
c.warnLog(ctx, "unable to restore web sessions: %v", err)
}
return nil
}(phaseCtx); err != nil {
return err
}

return nil
}

Expand Down Expand Up @@ -852,6 +864,9 @@ func TestingForceRandomizeDemoPorts() func() {
}

func (c *transientCluster) Close(ctx context.Context) {
if err := c.saveWebSessions(ctx); err != nil {
c.warnLog(ctx, "unable to save web sessions: %v", err)
}
if c.stopper != nil {
if r := recover(); r != nil {
// A panic here means some of the async tasks may still be
Expand Down
220 changes: 220 additions & 0 deletions pkg/cli/democluster/session_persistence.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
// Copyright 2022 The Cockroach Authors.
//
// Use of this software is governed by the Business Source License
// included in the file licenses/BSL.txt.
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0, included in the file
// licenses/APL.txt.

package democluster

import (
"bufio"
"context"
gosql "database/sql"
"encoding/json"
"io"
"os"
"path/filepath"

"github.com/cockroachdb/cockroach/pkg/security/certnames"
"github.com/cockroachdb/cockroach/pkg/security/username"
"github.com/cockroachdb/cockroach/pkg/server/pgurl"
"github.com/cockroachdb/cockroach/pkg/sql/sem/catconstants"
"github.com/cockroachdb/cockroach/pkg/util/netutil/addr"
"github.com/cockroachdb/errors"
"github.com/cockroachdb/errors/oserror"
"github.com/cockroachdb/redact"
)

// saveWebSessions persists any currently active web session to disk,
// so they can be restored when the demo shell starts again.
func (c *transientCluster) saveWebSessions(ctx context.Context) error {
return c.doPersistence(ctx, "saving", c.saveWebSessionsInternal)
}

// restoreWebSessions restores any currently active web session from disk.
func (c *transientCluster) restoreWebSessions(ctx context.Context) error {
return c.doPersistence(ctx, "restoring", c.restoreWebSessionsInternal)
}

func (c *transientCluster) doPersistence(
ctx context.Context,
word redact.SafeString,
fn func(ctx context.Context, filename string, db *gosql.DB) error,
) error {
if c.demoDir == "" {
// No directory to save to. Bail.
return nil
}

// Lock the output dir to prevent concurrent demo sessions
// from stamping each other over.
cleanup, err := c.lockDir(ctx, c.demoDir, "sessions")
if err != nil {
return err
}
defer cleanup()

// We compose the connection URL anew because
// getNetworkURLForServer() uses the 'demo' account and we want a
// root connection, using client certs.
//
// This will bypass any blockage caused by a mistaken HBA
// configuration by the user.
caCert := filepath.Join(c.demoDir, certnames.CACertFilename())
rootCert := filepath.Join(c.demoDir, certnames.ClientCertFilename(username.RootUserName()))
rootKey := filepath.Join(c.demoDir, certnames.ClientKeyFilename(username.RootUserName()))
u := pgurl.New().
WithDatabase(catconstants.SystemDatabaseName).
WithUsername(username.RootUser).
WithAuthn(pgurl.AuthnClientCert(rootCert, rootKey)).
WithTransport(pgurl.TransportTLS(pgurl.TLSRequire, caCert))

apply := func(filename string, u *pgurl.URL) error {
db, err := gosql.Open("postgres", u.ToPQ().String())
if err != nil {
return err
}
defer db.Close()
return fn(ctx, filename, db)
}

if c.demoCtx.Multitenant && len(c.tenantServers) > 0 && c.tenantServers[0] != nil {
sqlAddr := c.tenantServers[0].SQLAddr()
host, port, _ := addr.SplitHostPort(sqlAddr, "")
u.WithNet(pgurl.NetTCP(host, port))
if err := apply("sessions.app.txt", u); err != nil {
return errors.Wrapf(err, "%s for application tenant", word)
}
}

if c.servers[0].TestServer != nil {
sqlAddr := c.servers[0].ServingSQLAddr()
host, port, _ := addr.SplitHostPort(sqlAddr, "")
u.WithNet(pgurl.NetTCP(host, port))
return errors.Wrapf(
apply("sessions.system.txt", u),
"%s for for system tenant", word)
}
return nil
}

type webSessionRow struct {
ID int64
HashedSecret []byte
Username string
ExpiresAt string
}

// saveWebSessionsInternal saves the sessions for just one tenant to
// the provided filename.
func (c *transientCluster) saveWebSessionsInternal(
ctx context.Context, filename string, db *gosql.DB,
) error {
c.infoLog(ctx, "saving sessions")
rows, err := db.QueryContext(ctx, `
SELECT id, "hashedSecret", username, "expiresAt"
FROM system.web_sessions
WHERE "expiresAt" > now()
AND "revokedAt" IS NULL`)
if err != nil {
return err
}
defer rows.Close()

outputFile, err := os.Create(filepath.Join(c.demoDir, filename))
if err != nil {
return err
}
defer func() {
if err := outputFile.Close(); err != nil {
c.warnLog(ctx, "%v", err)
}
}()
buf := bufio.NewWriter(outputFile)
defer func() {
if err := buf.Flush(); err != nil {
c.warnLog(ctx, "%v", err)
}
}()

numSessions := 0
for rows.Next() {
var row webSessionRow
if err := rows.Scan(&row.ID, &row.HashedSecret, &row.Username, &row.ExpiresAt); err != nil {
return err
}
j, err := json.Marshal(row)
if err != nil {
return err
}
j = append(j, '\n')
if _, err := buf.Write(j); err != nil {
return err
}
numSessions++
}

c.infoLog(ctx, "saved %d sessions to %q", numSessions, filename)

return nil
}

// restoreWebSessionsInternal restores the sessions for just one
// tenant from the provided filename.
func (c *transientCluster) restoreWebSessionsInternal(
ctx context.Context, filename string, db *gosql.DB,
) error {
c.infoLog(ctx, "restoring sessions")

inputFile, err := os.Open(filepath.Join(c.demoDir, filename))
if err != nil {
if oserror.IsNotExist(err) {
// No file yet. That's OK.
return nil
}
return err
}
defer func() {
if err := inputFile.Close(); err != nil {
c.warnLog(ctx, "%v", err)
}
}()

buf := bufio.NewReader(inputFile)
numSessions := 0
for {
j, err := buf.ReadBytes('\n')
if err != nil {
if errors.Is(err, io.EOF) {
// Done reading. Nothing more to do.
break
}
return err
}

var row webSessionRow
if err := json.Unmarshal(j, &row); err != nil {
return err
}

if _, err := db.ExecContext(ctx, `
INSERT INTO system.web_sessions(id, "hashedSecret", username, "expiresAt")
VALUES ($1, $2, $3, $4)`,
row.ID,
row.HashedSecret,
row.Username,
row.ExpiresAt,
); err != nil {
return err
}
numSessions++
}

c.infoLog(ctx, "restored %d sessions from %q", numSessions, filename)

return nil
}
50 changes: 50 additions & 0 deletions pkg/cli/interactive_tests/test_demo_cli_integration.tcl
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
source [file join [file dirname $argv0] common.tcl]

set ::env(COCKROACH_INSECURE) "false"
set python "python2.7"

spawn $argv demo --empty --no-line-editor --multitenant=true
eexpect "Welcome"
Expand Down Expand Up @@ -72,6 +73,55 @@ send "\\q\r"
eexpect eof
end_test

spawn /bin/bash
set shell_spawn_id $spawn_id
send "PS1=':''/# '\r"
eexpect ":/# "

start_test "Check that an auth cookie can be extracted for a demo session"
# From the system tenant.
send "$argv auth-session login root --certs-dir=\$HOME/.cockroach-demo -p 26258 --only-cookie >cookie_system.txt\r"
eexpect ":/# "
# From the app tenant.
send "$argv auth-session login root --certs-dir=\$HOME/.cockroach-demo --only-cookie >cookie_app.txt\r"
eexpect ":/# "

# Check that the cookies work.
set pyfile [file join [file dirname $argv0] test_auth_cookie.py]

send "$python $pyfile cookie_system.txt 'http://localhost:8081/_admin/v1/users'\r"
eexpect "username"
eexpect "demo"
send "$python $pyfile cookie_app.txt 'http://localhost:8080/_admin/v1/users'\r"
eexpect "username"
eexpect "demo"
end_test


start_test "Check that login sessions are preserved across demo restarts."

set spawn_id $demo_spawn_id
send "\\q\r"
eexpect eof

spawn $argv demo --empty --no-line-editor --multitenant=true
set demo_spawn_id $spawn_id
eexpect "Welcome"
eexpect "defaultdb>"

set spawn_id $shell_spawn_id

send "$python $pyfile cookie_system.txt 'http://localhost:8081/_admin/v1/users'\r"
eexpect "username"
eexpect "demo"
send "$python $pyfile cookie_app.txt 'http://localhost:8080/_admin/v1/users'\r"
eexpect "username"
eexpect "demo"
end_test

send "exit\r"
eexpect eof

set spawn_id $demo_spawn_id
send "\\q\r"
eexpect eof

0 comments on commit f11f231

Please sign in to comment.