diff --git a/app/public/server.js b/app/public/server.js index eea62afd8..56f00e8d9 100644 --- a/app/public/server.js +++ b/app/public/server.js @@ -13,6 +13,7 @@ function newServerForRepo(repoID) { let runningServerProcess = null; let runningServerCertSHA256 = ""; let runningServerPassword = ""; + let runningServerControlPassword = ""; let runningServerAddress = ""; let runningServerCertificate = ""; let runningServerStatusDetails = { @@ -37,6 +38,7 @@ function newServerForRepo(repoID) { '--tls-print-server-cert', '--tls-generate-cert-name=localhost', '--random-password', + '--random-server-control-password', '--tls-generate-cert', '--shutdown-on-stdin', // shutdown the server when parent dies '--address=localhost:0'); @@ -65,7 +67,7 @@ function newServerForRepo(repoID) { const statusUpdated = this.raiseStatusUpdatedEvent.bind(this); function pollOnce() { - if (!runningServerAddress || !runningServerCertificate || !runningServerPassword) { + if (!runningServerAddress || !runningServerCertificate || !runningServerPassword || !runningServerControlPassword) { return; } @@ -74,9 +76,9 @@ function newServerForRepo(repoID) { host: "localhost", port: parseInt(new URL(runningServerAddress).port), method: "GET", - path: "/api/v1/repo/status", + path: "/api/v1/control/status", headers: { - 'Authorization': 'Basic ' + Buffer.from("kopia" + ':' + runningServerPassword).toString('base64') + 'Authorization': 'Basic ' + Buffer.from("server-control" + ':' + runningServerControlPassword).toString('base64') } }, (resp) => { if (resp.statusCode === 200) { @@ -110,6 +112,7 @@ function newServerForRepo(repoID) { runningServerAddress = ""; runningServerPassword = ""; + runningServerControlPassword = ""; runningServerCertSHA256 = ""; runningServerProcess = null; this.raiseStatusUpdatedEvent(); @@ -133,6 +136,11 @@ function newServerForRepo(repoID) { this.raiseStatusUpdatedEvent(); break; + case "SERVER CONTROL PASSWORD": + runningServerControlPassword = value; + this.raiseStatusUpdatedEvent(); + break; + case "SERVER CERT SHA256": runningServerCertSHA256 = value; this.raiseStatusUpdatedEvent(); diff --git a/cli/command_server.go b/cli/command_server.go index a5b299e18..d46fc73ee 100644 --- a/cli/command_server.go +++ b/cli/command_server.go @@ -8,16 +8,17 @@ import ( ) type commandServer struct { - acl commandServerACL - user commandServerUser - cancel commandServerCancel - flush commandServerFlush - pause commandServerPause - refresh commandServerRefresh - resume commandServerResume - start commandServerStart - status commandServerStatus - upload commandServerUpload + acl commandServerACL + user commandServerUser + cancel commandServerCancel + flush commandServerFlush + pause commandServerPause + refresh commandServerRefresh + resume commandServerResume + start commandServerStart + status commandServerStatus + upload commandServerUpload + shutdown commandServerShutdown } type serverFlags struct { @@ -33,28 +34,42 @@ func (c *serverFlags) setup(cmd *kingpin.CmdClause) { } type serverClientFlags struct { - serverFlags + serverAddress string + serverUsername string + serverPassword string serverCertFingerprint string } func (c *serverClientFlags) setup(cmd *kingpin.CmdClause) { - c.serverFlags.setup(cmd) - cmd.Flag("server-cert-fingerprint", "Server certificate fingerprint").StringVar(&c.serverCertFingerprint) + c.serverUsername = "server-control" + + cmd.Flag("address", "Address of the server to connect to").Envar("KOPIA_SERVER_ADDRESS").Default("http://127.0.0.1:51515").StringVar(&c.serverAddress) + cmd.Flag("server-control-username", "Server control username").Envar("KOPIA_SERVER_USERNAME").StringVar(&c.serverUsername) + cmd.Flag("server-control-password", "Server control password").PlaceHolder("PASSWORD").Envar("KOPIA_SERVER_PASSWORD").StringVar(&c.serverPassword) + + // aliases for backwards compat + cmd.Flag("server-username", "Server control username").Hidden().StringVar(&c.serverUsername) + cmd.Flag("server-password", "Server control password").Hidden().StringVar(&c.serverPassword) + + cmd.Flag("server-cert-fingerprint", "Server certificate fingerprint").PlaceHolder("SHA256-FINGERPRINT").Envar("KOPIA_SERVER_CERT_FINGERPRINT").StringVar(&c.serverCertFingerprint) } func (c *commandServer) setup(svc advancedAppServices, parent commandParent) { cmd := parent.Command("server", "Commands to control HTTP API server.") - c.cancel.setup(svc, cmd) - c.flush.setup(svc, cmd) - c.pause.setup(svc, cmd) - c.refresh.setup(svc, cmd) - c.resume.setup(svc, cmd) c.start.setup(svc, cmd) - c.status.setup(svc, cmd) - c.upload.setup(svc, cmd) c.acl.setup(svc, cmd) c.user.setup(svc, cmd) + + c.status.setup(svc, cmd) + c.refresh.setup(svc, cmd) + c.flush.setup(svc, cmd) + c.shutdown.setup(svc, cmd) + + c.upload.setup(svc, cmd) + c.cancel.setup(svc, cmd) + c.pause.setup(svc, cmd) + c.resume.setup(svc, cmd) } func (c *serverClientFlags) serverAPIClientOptions() (apiclient.Options, error) { diff --git a/cli/command_server_cancel.go b/cli/command_server_cancel.go index 9c6710fd3..28eb51ac4 100644 --- a/cli/command_server_cancel.go +++ b/cli/command_server_cancel.go @@ -4,20 +4,18 @@ import ( "context" "github.com/kopia/kopia/internal/apiclient" - "github.com/kopia/kopia/internal/serverapi" ) type commandServerCancel struct { - sf serverClientFlags + commandServerSourceManagerAction } func (c *commandServerCancel) setup(svc appServices, parent commandParent) { cmd := parent.Command("cancel", "Cancels in-progress uploads for one or more sources") - c.sf.setup(cmd) + c.commandServerSourceManagerAction.setup(svc, cmd) cmd.Action(svc.serverAction(&c.sf, c.runServerCancelUpload)) } func (c *commandServerCancel) runServerCancelUpload(ctx context.Context, cli *apiclient.KopiaAPIClient) error { - // nolint:wrapcheck - return cli.Post(ctx, "sources/cancel", &serverapi.Empty{}, &serverapi.Empty{}) + return c.triggerActionOnMatchingSources(ctx, cli, "control/cancel-snapshot") } diff --git a/cli/command_server_control_test.go b/cli/command_server_control_test.go new file mode 100644 index 000000000..79a4d26c8 --- /dev/null +++ b/cli/command_server_control_test.go @@ -0,0 +1,121 @@ +package cli_test + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/kopia/kopia/internal/testutil" + "github.com/kopia/kopia/tests/testenv" +) + +func TestServerControl(t *testing.T) { + env := testenv.NewCLITest(t, testenv.RepoFormatNotImportant, testenv.NewInProcRunner(t)) + + dir0 := testutil.TempDirectory(t) + dir1 := testutil.TempDirectory(t) + dir2 := testutil.TempDirectory(t) + dir3 := testutil.TempDirectory(t) + + env.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", env.RepoDir, "--override-username=another-user", "--override-hostname=another-host") + env.RunAndExpectSuccess(t, "snap", "create", dir0) + + env.RunAndExpectSuccess(t, "repo", "connect", "filesystem", "--path", env.RepoDir, "--override-username=test-user", "--override-hostname=test-host") + env.RunAndExpectSuccess(t, "snap", "create", dir1) + env.RunAndExpectSuccess(t, "snap", "create", dir2) + + serverStarted := make(chan struct{}) + serverStopped := make(chan struct{}) + + // detected from running server + var ( + serverAddress string + serverControlPassword string + ) + + go func() { + prefix := "SERVER ADDRESS: " + passwordPrefix := "SERVER CONTROL PASSWORD: " + + kill := env.RunAndProcessStderr(t, func(line string) bool { + if strings.HasPrefix(line, passwordPrefix) { + serverControlPassword = strings.TrimPrefix(line, passwordPrefix) + return true + } + + if strings.HasPrefix(line, prefix) { + serverAddress = strings.TrimPrefix(line, prefix) + close(serverStarted) + return false + } + + return true + }, "server", "start", "--insecure", "--random-server-control-password", "--address=127.0.0.1:0") + defer kill() + + close(serverStopped) + }() + + select { + case <-serverStarted: + t.Logf("server started on %v", serverAddress) + + case <-time.After(5 * time.Second): + t.Fatalf("server did not start in time") + } + + time.Sleep(time.Second) + + lines := env.RunAndExpectSuccess(t, "server", "status", "--address", serverAddress, "--server-control-password", serverControlPassword) + require.Len(t, lines, 2) + require.Contains(t, lines, "IDLE: test-user@test-host:"+dir1) + require.Contains(t, lines, "IDLE: test-user@test-host:"+dir2) + + lines = env.RunAndExpectSuccess(t, "server", "status", "--address", serverAddress, "--server-control-password", serverControlPassword, "--remote") + require.Len(t, lines, 3) + require.Contains(t, lines, "IDLE: test-user@test-host:"+dir1) + require.Contains(t, lines, "IDLE: test-user@test-host:"+dir2) + require.Contains(t, lines, "REMOTE: another-user@another-host:"+dir0) + + // create snapshot outside of the server + env.RunAndExpectSuccess(t, "snap", "create", dir3) + env.RunAndExpectSuccess(t, "server", "refresh", "--address", serverAddress, "--server-control-password", serverControlPassword) + + lines = env.RunAndExpectSuccess(t, "server", "status", "--address", serverAddress, "--server-control-password", serverControlPassword, "--remote") + require.Len(t, lines, 4) + require.Contains(t, lines, "IDLE: test-user@test-host:"+dir3) + + env.RunAndExpectSuccess(t, "server", "flush", "--address", serverAddress, "--server-control-password", serverControlPassword) + + // trigger server snapshot + env.RunAndExpectSuccess(t, "server", "snapshot", "--address", serverAddress, "--server-control-password", serverControlPassword, "--all") + env.RunAndExpectSuccess(t, "server", "snapshot", "--address", serverAddress, "--server-control-password", serverControlPassword, dir1) + env.RunAndExpectFailure(t, "server", "snapshot", "--address", serverAddress, "--server-control-password", serverControlPassword, "no-such-dir") + + // neither dir nor --all specified + env.RunAndExpectFailure(t, "server", "snapshot", "--address", serverAddress, "--server-control-password", serverControlPassword) + + // cancel snapshot + env.RunAndExpectSuccess(t, "server", "cancel", "--address", serverAddress, "--server-control-password", serverControlPassword, "--all") + + env.RunAndExpectSuccess(t, "server", "pause", "--address", serverAddress, "--server-control-password", serverControlPassword, dir1) + env.RunAndExpectSuccess(t, "server", "resume", "--address", serverAddress, "--server-control-password", serverControlPassword, dir1) + + env.RunAndExpectSuccess(t, "server", "shutdown", "--address", serverAddress, "--server-control-password", serverControlPassword) + + select { + case <-serverStopped: + t.Logf("server shut down") + + case <-time.After(5 * time.Second): + t.Fatalf("server did not shutdown in time") + } + + // this will fail since the server is down + env.RunAndExpectFailure(t, "server", "status", "--address", serverAddress, "--server-control-password", serverControlPassword) + env.RunAndExpectFailure(t, "server", "flush", "--address", serverAddress, "--server-control-password", serverControlPassword) + env.RunAndExpectFailure(t, "server", "refresh", "--address", serverAddress, "--server-control-password", serverControlPassword) + env.RunAndExpectFailure(t, "server", "shutdown", "--address", serverAddress, "--server-control-password", serverControlPassword) +} diff --git a/cli/command_server_flush.go b/cli/command_server_flush.go index ea93b9d33..0d347c097 100644 --- a/cli/command_server_flush.go +++ b/cli/command_server_flush.go @@ -19,5 +19,5 @@ func (c *commandServerFlush) setup(svc appServices, parent commandParent) { func (c *commandServerFlush) run(ctx context.Context, cli *apiclient.KopiaAPIClient) error { // nolint:wrapcheck - return cli.Post(ctx, "flush", &serverapi.Empty{}, &serverapi.Empty{}) + return cli.Post(ctx, "control/flush", &serverapi.Empty{}, &serverapi.Empty{}) } diff --git a/cli/command_server_pause.go b/cli/command_server_pause.go index 7f11a5c9c..c0bbb607f 100644 --- a/cli/command_server_pause.go +++ b/cli/command_server_pause.go @@ -4,20 +4,18 @@ import ( "context" "github.com/kopia/kopia/internal/apiclient" - "github.com/kopia/kopia/internal/serverapi" ) type commandServerPause struct { - sf serverClientFlags + commandServerSourceManagerAction } func (c *commandServerPause) setup(svc appServices, parent commandParent) { cmd := parent.Command("pause", "Pause the scheduled snapshots for one or more sources") - c.sf.setup(cmd) - cmd.Action(svc.serverAction(&c.sf, runServerPause)) + c.commandServerSourceManagerAction.setup(svc, cmd) + cmd.Action(svc.serverAction(&c.sf, c.run)) } -func runServerPause(ctx context.Context, cli *apiclient.KopiaAPIClient) error { - // nolint:wrapcheck - return cli.Post(ctx, "sources/pause", &serverapi.Empty{}, &serverapi.Empty{}) +func (c *commandServerPause) run(ctx context.Context, cli *apiclient.KopiaAPIClient) error { + return c.triggerActionOnMatchingSources(ctx, cli, "control/pause-source") } diff --git a/cli/command_server_refresh.go b/cli/command_server_refresh.go index cd621da0c..80b7c98b8 100644 --- a/cli/command_server_refresh.go +++ b/cli/command_server_refresh.go @@ -19,5 +19,5 @@ func (c *commandServerRefresh) setup(svc appServices, parent commandParent) { func (c *commandServerRefresh) run(ctx context.Context, cli *apiclient.KopiaAPIClient) error { // nolint:wrapcheck - return cli.Post(ctx, "refresh", &serverapi.Empty{}, &serverapi.Empty{}) + return cli.Post(ctx, "control/refresh", &serverapi.Empty{}, &serverapi.Empty{}) } diff --git a/cli/command_server_resume.go b/cli/command_server_resume.go index f2465a75b..dc3e9e1e1 100644 --- a/cli/command_server_resume.go +++ b/cli/command_server_resume.go @@ -4,20 +4,18 @@ import ( "context" "github.com/kopia/kopia/internal/apiclient" - "github.com/kopia/kopia/internal/serverapi" ) type commandServerResume struct { - sf serverClientFlags + commandServerSourceManagerAction } func (c *commandServerResume) setup(svc appServices, parent commandParent) { - cmd := parent.Command("resume", "Resume the scheduled snapshots for one or more sources") - c.sf.setup(cmd) + cmd := parent.Command("resume", "Resume the scheduled snapshots for one or more sources").Alias("unpause") + c.commandServerSourceManagerAction.setup(svc, cmd) cmd.Action(svc.serverAction(&c.sf, c.run)) } func (c *commandServerResume) run(ctx context.Context, cli *apiclient.KopiaAPIClient) error { - // nolint:wrapcheck - return cli.Post(ctx, "sources/resume", &serverapi.Empty{}, &serverapi.Empty{}) + return c.triggerActionOnMatchingSources(ctx, cli, "control/resume-source") } diff --git a/cli/command_server_shutdown.go b/cli/command_server_shutdown.go new file mode 100644 index 000000000..17c24f106 --- /dev/null +++ b/cli/command_server_shutdown.go @@ -0,0 +1,26 @@ +package cli + +import ( + "context" + + "github.com/kopia/kopia/internal/apiclient" + "github.com/kopia/kopia/internal/serverapi" +) + +type commandServerShutdown struct { + sf serverClientFlags + + out textOutput +} + +func (c *commandServerShutdown) setup(svc appServices, parent commandParent) { + cmd := parent.Command("shutdown", "Gracefully shutdown the server") + c.sf.setup(cmd) + c.out.setup(svc) + cmd.Action(svc.serverAction(&c.sf, c.run)) +} + +func (c *commandServerShutdown) run(ctx context.Context, cli *apiclient.KopiaAPIClient) error { + // nolint:wrapcheck + return cli.Post(ctx, "control/shutdown", &serverapi.Empty{}, &serverapi.Empty{}) +} diff --git a/cli/command_server_snapshot.go b/cli/command_server_snapshot.go new file mode 100644 index 000000000..02b3815b7 --- /dev/null +++ b/cli/command_server_snapshot.go @@ -0,0 +1,22 @@ +package cli + +import ( + "context" + + "github.com/kopia/kopia/internal/apiclient" +) + +type commandServerUpload struct { + commandServerSourceManagerAction +} + +func (c *commandServerUpload) setup(svc appServices, parent commandParent) { + cmd := parent.Command("snapshot", "Trigger upload for one or more existing sources").Alias("upload") + + c.commandServerSourceManagerAction.setup(svc, cmd) + cmd.Action(svc.serverAction(&c.sf, c.run)) +} + +func (c *commandServerUpload) run(ctx context.Context, cli *apiclient.KopiaAPIClient) error { + return c.triggerActionOnMatchingSources(ctx, cli, "control/trigger-snapshot") +} diff --git a/cli/command_server_source_manager_action.go b/cli/command_server_source_manager_action.go new file mode 100644 index 000000000..aec69c84e --- /dev/null +++ b/cli/command_server_source_manager_action.go @@ -0,0 +1,66 @@ +package cli + +import ( + "context" + "net/url" + "path/filepath" + + "github.com/alecthomas/kingpin" + "github.com/pkg/errors" + + "github.com/kopia/kopia/internal/apiclient" + "github.com/kopia/kopia/internal/serverapi" +) + +// commandServerSourceManagerAction encapsulates common logic for all commands +// that operate on snapshot sources managed by the server and can be used to +// act upon them one-by-one or all at the same time. +type commandServerSourceManagerAction struct { + sf serverClientFlags + + source string + all bool + + out textOutput +} + +func (c *commandServerSourceManagerAction) setup(svc appServices, cmd *kingpin.CmdClause) { + cmd.Flag("all", "All paths managed by server").BoolVar(&c.all) + cmd.Arg("source", "Source path managed by server").StringVar(&c.source) + + c.sf.setup(cmd) + c.out.setup(svc) +} + +func (c *commandServerSourceManagerAction) triggerActionOnMatchingSources(ctx context.Context, cli *apiclient.KopiaAPIClient, path string) error { + var resp serverapi.MultipleSourceActionResponse + + uv := url.Values{} + + if !c.all { + if c.source == "" { + return errors.Errorf("must specify source or --all") + } + + absPath, err := filepath.Abs(c.source) + if err != nil { + return errors.Wrap(err, "unable to determine absolute path") + } + + uv.Set("path", absPath) + } + + if err := cli.Post(ctx, path+"?"+uv.Encode(), &serverapi.Empty{}, &resp); err != nil { + return errors.Wrapf(err, "server returned error") + } + + for src, resp := range resp.Sources { + if resp.Success { + log(ctx).Infof("Success %v", src) + } else { + log(ctx).Warnf("Failed %v", src) + } + } + + return nil +} diff --git a/cli/command_server_start.go b/cli/command_server_start.go index abd74a918..b71a2b79e 100644 --- a/cli/command_server_start.go +++ b/cli/command_server_start.go @@ -43,6 +43,10 @@ type commandServerStart struct { serverStartRandomPassword bool serverStartHtpasswdFile string + randomServerControlPassword bool + serverControlUsername string + serverControlPassword string + serverAuthCookieSingingKey string serverStartShutdownWhenStdinClosed bool @@ -80,6 +84,10 @@ func (c *commandServerStart) setup(svc advancedAppServices, parent commandParent cmd.Flag("random-password", "Generate random password and print to stderr").Hidden().BoolVar(&c.serverStartRandomPassword) cmd.Flag("htpasswd-file", "Path to htpasswd file that contains allowed user@hostname entries").Hidden().ExistingFileVar(&c.serverStartHtpasswdFile) + cmd.Flag("random-server-control-password", "Generate random server control password and print to stderr").Hidden().BoolVar(&c.randomServerControlPassword) + cmd.Flag("server-control-username", "Server control username").Default("server-control").Envar("KOPIA_SERVER_CONTROL_USER").StringVar(&c.serverControlUsername) + cmd.Flag("server-control-password", "Server control password").PlaceHolder("PASSWORD").Envar("KOPIA_SERVER_CONTROL_PASSWORD").StringVar(&c.serverControlPassword) + cmd.Flag("auth-cookie-signing-key", "Force particular auth cookie signing key").Envar("KOPIA_AUTH_COOKIE_SIGNING_KEY").Hidden().StringVar(&c.serverAuthCookieSingingKey) cmd.Flag("shutdown-on-stdin", "Shut down the server when stdin handle has closed.").Hidden().BoolVar(&c.serverStartShutdownWhenStdinClosed) @@ -129,6 +137,7 @@ func (c *commandServerStart) run(ctx context.Context, rep repo.Repository) error Authorizer: auth.DefaultAuthorizer(), AuthCookieSigningKey: c.serverAuthCookieSingingKey, UIUser: c.sf.serverUsername, + ServerControlUser: c.serverControlUsername, LogRequests: c.logServerRequests, PasswordPersist: c.svc.passwordPersistenceStrategy(), UIPreferencesFile: uiPreferencesFile, @@ -277,6 +286,24 @@ func (c *commandServerStart) getAuthenticator(ctx context.Context) (auth.Authent authenticators = append(authenticators, auth.AuthenticateSingleUser(c.sf.serverUsername, randomPassword)) } + // handle server control password + switch { + case c.serverControlPassword != "": + authenticators = append(authenticators, auth.AuthenticateSingleUser(c.serverControlUsername, c.serverControlPassword)) + + case c.randomServerControlPassword: + // generate very long random one-time password + b := make([]byte, serverRandomPasswordLength) + io.ReadFull(rand.Reader, b) //nolint:errcheck + + randomPassword := hex.EncodeToString(b) + + // print it to the stderr bypassing any log file so that the user or calling process can connect + fmt.Fprintln(c.out.stderr(), "SERVER CONTROL PASSWORD:", randomPassword) + + authenticators = append(authenticators, auth.AuthenticateSingleUser(c.serverControlUsername, randomPassword)) + } + log(ctx).Infof(` Server will allow connections from users whose accounts are stored in the repository. User accounts can be added using 'kopia server user add'. diff --git a/cli/command_server_status.go b/cli/command_server_status.go index 346068d31..61e2935fe 100644 --- a/cli/command_server_status.go +++ b/cli/command_server_status.go @@ -13,11 +13,15 @@ type commandServerStatus struct { sf serverClientFlags out textOutput + + remote bool } func (c *commandServerStatus) setup(svc appServices, parent commandParent) { cmd := parent.Command("status", "Status of Kopia server") + cmd.Flag("remote", "Show remote sources").BoolVar(&c.remote) + c.sf.setup(cmd) c.out.setup(svc) @@ -26,12 +30,16 @@ func (c *commandServerStatus) setup(svc appServices, parent commandParent) { func (c *commandServerStatus) runServerStatus(ctx context.Context, cli *apiclient.KopiaAPIClient) error { var status serverapi.SourcesResponse - if err := cli.Get(ctx, "sources", nil, &status); err != nil { + if err := cli.Get(ctx, "control/sources", nil, &status); err != nil { return errors.Wrap(err, "unable to list sources") } for _, src := range status.Sources { - c.out.printStdout("%15v %v\n", src.Status, src.Source) + if src.Status == "REMOTE" && !c.remote { + continue + } + + c.out.printStdout("%v: %v\n", src.Status, src.Source) } return nil diff --git a/cli/command_server_upload.go b/cli/command_server_upload.go deleted file mode 100644 index 9f363a110..000000000 --- a/cli/command_server_upload.go +++ /dev/null @@ -1,45 +0,0 @@ -package cli - -import ( - "context" - - "github.com/pkg/errors" - - "github.com/kopia/kopia/internal/apiclient" - "github.com/kopia/kopia/internal/serverapi" -) - -type commandServerUpload struct { - sf serverClientFlags - - out textOutput -} - -func (c *commandServerUpload) setup(svc appServices, parent commandParent) { - cmd := parent.Command("upload", "Trigger upload for one or more sources") - c.sf.setup(cmd) - c.out.setup(svc) - cmd.Action(svc.serverAction(&c.sf, c.run)) -} - -func (c *commandServerUpload) run(ctx context.Context, cli *apiclient.KopiaAPIClient) error { - return c.triggerActionOnMatchingSources(ctx, cli, "sources/upload") -} - -func (c *commandServerUpload) triggerActionOnMatchingSources(ctx context.Context, cli *apiclient.KopiaAPIClient, path string) error { - var resp serverapi.MultipleSourceActionResponse - - if err := cli.Post(ctx, path, &serverapi.Empty{}, &resp); err != nil { - return errors.Wrapf(err, "unable to start upload on %v", path) - } - - for src, resp := range resp.Sources { - if resp.Success { - c.out.printStdout("SUCCESS %v\n", src) - } else { - c.out.printStdout("FAILED %v\n", src) - } - } - - return nil -} diff --git a/go.mod b/go.mod index 3a42f047b..5b259b698 100644 --- a/go.mod +++ b/go.mod @@ -93,7 +93,7 @@ require ( github.com/googleapis/gax-go/v2 v2.1.1 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/kopia/htmluibuild v0.0.0-20211226001320-eed13dea3815 + github.com/kopia/htmluibuild v0.0.0-20220103200652-9a7b3d8f5556 github.com/kr/fs v0.1.0 // indirect github.com/mattn/go-ieproxy v0.0.1 // indirect github.com/mattn/go-isatty v0.0.14 // indirect diff --git a/go.sum b/go.sum index 3565cd5f6..31554d81b 100644 --- a/go.sum +++ b/go.sum @@ -395,6 +395,8 @@ github.com/kopia/htmluibuild v0.0.0-20211223182507-6b6a50b2f0d1 h1:stwETPrkIzdLA github.com/kopia/htmluibuild v0.0.0-20211223182507-6b6a50b2f0d1/go.mod h1:eWer4rx9P8lJo2eKc+Q7AZ1dE1x1hJNdkbDFPzMu1Hw= github.com/kopia/htmluibuild v0.0.0-20211226001320-eed13dea3815 h1:tYyVCQw7MOqceTvuKIWQ2eCp/caJg5HRp1pO0Q68Z7Y= github.com/kopia/htmluibuild v0.0.0-20211226001320-eed13dea3815/go.mod h1:eWer4rx9P8lJo2eKc+Q7AZ1dE1x1hJNdkbDFPzMu1Hw= +github.com/kopia/htmluibuild v0.0.0-20220103200652-9a7b3d8f5556 h1:ILqrzWYFuTpTAcAW5GMujLlnxs7v/cvlrpSjUWpUmco= +github.com/kopia/htmluibuild v0.0.0-20220103200652-9a7b3d8f5556/go.mod h1:eWer4rx9P8lJo2eKc+Q7AZ1dE1x1hJNdkbDFPzMu1Hw= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= diff --git a/internal/server/server.go b/internal/server/server.go index ad5cbbcd9..c0a096fb4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -92,7 +92,6 @@ func (s *Server) APIHandlers(legacyAPI bool) http.Handler { m.HandleFunc("/api/v1/policies", s.handleAPI(requireUIUser, s.handlePolicyList)).Methods(http.MethodGet) m.HandleFunc("/api/v1/refresh", s.handleAPI(anyAuthenticatedUser, s.handleRefresh)).Methods(http.MethodPost) - m.HandleFunc("/api/v1/shutdown", s.handleAPIPossiblyNotConnected(requireUIUser, s.handleShutdown)).Methods(http.MethodPost) m.HandleFunc("/api/v1/objects/{objectID}", s.requireAuth(s.handleObjectGet)).Methods(http.MethodGet) m.HandleFunc("/api/v1/restore", s.handleAPI(requireUIUser, s.handleRestore)).Methods(http.MethodPost) @@ -106,7 +105,7 @@ func (s *Server) APIHandlers(legacyAPI bool) http.Handler { // methods that can be called by any authenticated user (UI or remote user). m.HandleFunc("/api/v1/flush", s.handleAPI(anyAuthenticatedUser, s.handleFlush)).Methods(http.MethodPost) - m.HandleFunc("/api/v1/repo/status", s.handleAPIPossiblyNotConnected(anyAuthenticatedUser, s.handleRepoStatus)).Methods(http.MethodGet) + m.HandleFunc("/api/v1/repo/status", s.handleAPIPossiblyNotConnected(requireUIUser, s.handleRepoStatus)).Methods(http.MethodGet) m.HandleFunc("/api/v1/repo/sync", s.handleAPI(anyAuthenticatedUser, s.handleRepoSync)).Methods(http.MethodPost) m.HandleFunc("/api/v1/repo/connect", s.handleAPIPossiblyNotConnected(requireUIUser, s.handleRepoConnect)).Methods(http.MethodPost) @@ -147,6 +146,17 @@ func (s *Server) APIHandlers(legacyAPI bool) http.Handler { m.HandleFunc("/api/v1/tasks/{taskID}/logs", s.handleAPI(requireUIUser, s.handleTaskLogs)).Methods(http.MethodGet) m.HandleFunc("/api/v1/tasks/{taskID}/cancel", s.handleAPI(requireUIUser, s.handleTaskCancel)).Methods(http.MethodPost) + // server control API, requires authentication as `server-control`. + m.HandleFunc("/api/v1/control/status", s.handleAPIPossiblyNotConnected(requireServerControlUser, s.handleRepoStatus)).Methods(http.MethodGet) + m.HandleFunc("/api/v1/control/sources", s.handleAPI(requireServerControlUser, s.handleSourcesList)).Methods(http.MethodGet) + m.HandleFunc("/api/v1/control/flush", s.handleAPIPossiblyNotConnected(requireServerControlUser, s.handleFlush)).Methods(http.MethodPost) + m.HandleFunc("/api/v1/control/refresh", s.handleAPIPossiblyNotConnected(requireServerControlUser, s.handleRefresh)).Methods(http.MethodPost) + m.HandleFunc("/api/v1/control/shutdown", s.handleAPI(requireServerControlUser, s.handleShutdown)).Methods(http.MethodPost) + m.HandleFunc("/api/v1/control/trigger-snapshot", s.handleAPI(requireServerControlUser, s.handleUpload)).Methods(http.MethodPost) + m.HandleFunc("/api/v1/control/cancel-snapshot", s.handleAPI(requireServerControlUser, s.handleCancel)).Methods(http.MethodPost) + m.HandleFunc("/api/v1/control/pause-source", s.handleAPI(requireServerControlUser, s.handlePause)).Methods(http.MethodPost) + m.HandleFunc("/api/v1/control/resume-source", s.handleAPI(requireServerControlUser, s.handleResume)).Methods(http.MethodPost) + return m } @@ -400,6 +410,10 @@ func (s *Server) forAllSourceManagersMatchingURLFilter(ctx context.Context, c fu } for src, mgr := range s.sourceManagers { + if mgr.isReadOnly { + continue + } + if !sourceMatchesURLFilter(src, values) { continue } @@ -407,6 +421,10 @@ func (s *Server) forAllSourceManagersMatchingURLFilter(ctx context.Context, c fu resp.Sources[src.String()] = c(mgr, ctx) } + if len(resp.Sources) == 0 { + return nil, notFoundError("no source matching the provided filters") + } + return resp, nil } @@ -418,6 +436,14 @@ func (s *Server) handleCancel(ctx context.Context, r *http.Request, body []byte) return s.forAllSourceManagersMatchingURLFilter(ctx, (*sourceManager).cancel, r.URL.Query()) } +func (s *Server) handlePause(ctx context.Context, r *http.Request, body []byte) (interface{}, *apiError) { + return s.forAllSourceManagersMatchingURLFilter(ctx, (*sourceManager).pause, r.URL.Query()) +} + +func (s *Server) handleResume(ctx context.Context, r *http.Request, body []byte) (interface{}, *apiError) { + return s.forAllSourceManagersMatchingURLFilter(ctx, (*sourceManager).resume, r.URL.Query()) +} + func (s *Server) beginUpload(ctx context.Context, src snapshot.SourceInfo) { log(ctx).Debugf("waiting on semaphore to upload %v", src) s.uploadSemaphore <- struct{}{} @@ -697,6 +723,10 @@ func (s *Server) ServeStaticFiles(fs http.FileSystem) http.Handler { r = r2 } + if !s.isAuthenticated(w, r) { + return + } + if !requireUIUser(s, r) { http.Error(w, `UI Access denied. See https://github.com/kopia/kopia/issues/880#issuecomment-798421751 for more information.`, http.StatusForbidden) return @@ -724,6 +754,7 @@ type Options struct { LogRequests bool UIUser string // name of the user allowed to access the UI UIPreferencesFile string // name of the JSON file storing UI preferences + ServerControlUser string // name of the user allowed to access the server control API DisableCSRFTokenChecks bool UITitlePrefix string } diff --git a/internal/server/server_authz_checks.go b/internal/server/server_authz_checks.go index 9c07f2312..7d8ea73ea 100644 --- a/internal/server/server_authz_checks.go +++ b/internal/server/server_authz_checks.go @@ -17,6 +17,16 @@ func requireUIUser(s *Server, r *http.Request) bool { return user == s.options.UIUser } +func requireServerControlUser(s *Server, r *http.Request) bool { + if s.authenticator == nil { + return true + } + + user, _, _ := r.BasicAuth() + + return user == s.options.ServerControlUser +} + func anyAuthenticatedUser(s *Server, r *http.Request) bool { return true } diff --git a/internal/server/source_manager.go b/internal/server/source_manager.go index 74e084964..8c49874e4 100644 --- a/internal/server/source_manager.go +++ b/internal/server/source_manager.go @@ -53,6 +53,8 @@ type sourceManager struct { lastSnapshot *snapshot.Manifest lastCompleteSnapshot *snapshot.Manifest manifestsSinceLastCompleteSnapshot []*snapshot.Manifest + paused bool + isReadOnly bool progress *snapshotfs.CountingUploadProgress currentTask string @@ -132,7 +134,12 @@ func (s *sourceManager) runLocal(ctx context.Context) { waitTime = oneDay } - s.setStatus("IDLE") + if s.paused { + s.setStatus("PAUSED") + } else { + s.setStatus("IDLE") + } + select { case <-s.closed: return @@ -174,6 +181,7 @@ func (s *sourceManager) backoffBeforeNextSnapshot() { } func (s *sourceManager) runReadOnly(ctx context.Context) { + s.isReadOnly = true s.refreshStatus(ctx) s.setStatus("REMOTE") @@ -202,7 +210,7 @@ func (s *sourceManager) upload(ctx context.Context) serverapi.SourceActionRespon } func (s *sourceManager) cancel(ctx context.Context) serverapi.SourceActionResponse { - log(ctx).Infof("cancel triggered via API: %v", s.src) + log(ctx).Debugw("cancel triggered via API", "source", s.src) if u := s.currentUploader(); u != nil { log(ctx).Infof("canceling current upload") @@ -212,6 +220,41 @@ func (s *sourceManager) cancel(ctx context.Context) serverapi.SourceActionRespon return serverapi.SourceActionResponse{Success: true} } +func (s *sourceManager) pause(ctx context.Context) serverapi.SourceActionResponse { + log(ctx).Debugw("pause triggered via API", "source", s.src) + + s.mu.Lock() + s.paused = true + s.mu.Unlock() + + if u := s.currentUploader(); u != nil { + log(ctx).Infof("canceling current upload") + u.Cancel() + } else { + select { + case s.refreshRequested <- struct{}{}: + default: + } + } + + return serverapi.SourceActionResponse{Success: true} +} + +func (s *sourceManager) resume(ctx context.Context) serverapi.SourceActionResponse { + log(ctx).Debugw("resume triggered via API", "source", s.src) + + s.mu.Lock() + s.paused = false + s.mu.Unlock() + + select { + case s.refreshRequested <- struct{}{}: + default: + } + + return serverapi.SourceActionResponse{Success: true} +} + func (s *sourceManager) stop(ctx context.Context) { log(ctx).Debugf("stopping source manager for %v", s.src) @@ -369,7 +412,11 @@ func (s *sourceManager) refreshStatus(ctx context.Context) { s.lastSnapshot = nil } - s.nextSnapshotTime = s.findClosestNextSnapshotTime() + if s.paused { + s.nextSnapshotTime = nil + } else { + s.nextSnapshotTime = s.findClosestNextSnapshotTime() + } } type uitaskProgress struct { diff --git a/internal/serverapi/client_wrappers.go b/internal/serverapi/client_wrappers.go index c36205b80..d49965ee7 100644 --- a/internal/serverapi/client_wrappers.go +++ b/internal/serverapi/client_wrappers.go @@ -83,16 +83,16 @@ func DisconnectFromRepository(ctx context.Context, c *apiclient.KopiaAPIClient) return c.Post(ctx, "repo/disconnect", &Empty{}, &Empty{}) } -// Shutdown invokes the 'repo/shutdown' API. +// Shutdown invokes the 'control/shutdown' API. func Shutdown(ctx context.Context, c *apiclient.KopiaAPIClient) error { // nolint:wrapcheck - return c.Post(ctx, "shutdown", &Empty{}, &Empty{}) + return c.Post(ctx, "control/shutdown", &Empty{}, &Empty{}) } -// Status invokes the 'repo/status' API. +// Status invokes the 'control/status' API. func Status(ctx context.Context, c *apiclient.KopiaAPIClient) (*StatusResponse, error) { resp := &StatusResponse{} - if err := c.Get(ctx, "repo/status", nil, resp); err != nil { + if err := c.Get(ctx, "control/status", nil, resp); err != nil { return nil, errors.Wrap(err, "Status") } diff --git a/tests/end_to_end_test/acl_test.go b/tests/end_to_end_test/acl_test.go index bbd8bb4da..0440464b3 100644 --- a/tests/end_to_end_test/acl_test.go +++ b/tests/end_to_end_test/acl_test.go @@ -44,8 +44,8 @@ func TestACL(t *testing.T) { kill := serverEnvironment.RunAndProcessStderr(t, sp.ProcessOutput, "server", "start", "--address=localhost:0", - "--server-username=admin-user", - "--server-password=admin-pwd", + "--server-control-username=admin-user", + "--server-control-password=admin-pwd", "--tls-generate-cert", "--tls-generate-rsa-key-size=2048", // use shorter key size to speed up generation ) @@ -126,11 +126,8 @@ func TestACL(t *testing.T) { "--server-cert-fingerprint", sp.sha256Fingerprint, ) - // we could use foo@bar's credentials as well (any valid user/password will do) - // since the password was and is valid, but using alice@wonderland with password 'baz' - // is not reliable since we don't know whether the server has already picked up the - // change or is still using the old password. - serverEnvironment.RunAndExpectSuccess(t, "server", "refresh", + // attempt to use foo@bar's credentials when refreshing, this will fail. + serverEnvironment.RunAndExpectFailure(t, "server", "refresh", "--address", sp.baseURL, "--server-username", "foo@bar", "--server-password", "baz", diff --git a/tests/end_to_end_test/api_server_repository_test.go b/tests/end_to_end_test/api_server_repository_test.go index f500c93c3..1f0b55591 100644 --- a/tests/end_to_end_test/api_server_repository_test.go +++ b/tests/end_to_end_test/api_server_repository_test.go @@ -25,6 +25,9 @@ var htpasswdFileContents = []byte("foo@bar:$2y$05$JWrExvBe5Knh0.AMLk5WHu.EzfOP.L const ( uiUsername = "ui-user-password" uiPassword = "ui-password" + + controlUsername = "control-user-password" + controlPassword = "control-password" ) func TestAPIServerRepository_GRPC_htpasswd(t *testing.T) { @@ -101,13 +104,15 @@ func testAPIServerRepository(t *testing.T, serverStartArgs []string, useGRPC, al "--tls-cert-file", tlsCert, "--server-username", uiUsername, "--server-password", uiPassword, + "--server-control-username", controlUsername, + "--server-control-password", controlPassword, }, serverStartArgs...)...) t.Logf("detected server parameters %#v", sp) - cli, err := apiclient.NewKopiaAPIClient(apiclient.Options{ + controlClient, err := apiclient.NewKopiaAPIClient(apiclient.Options{ BaseURL: sp.baseURL, - Username: "foo@bar", - Password: "baz", + Username: controlUsername, + Password: controlPassword, TrustedServerCertificateFingerprint: sp.sha256Fingerprint, LogRequests: true, }) @@ -115,18 +120,7 @@ func testAPIServerRepository(t *testing.T, serverStartArgs []string, useGRPC, al t.Fatalf("unable to create API apiclient") } - uiUserCLI, err := apiclient.NewKopiaAPIClient(apiclient.Options{ - BaseURL: sp.baseURL, - Username: uiUsername, - Password: uiPassword, - TrustedServerCertificateFingerprint: sp.sha256Fingerprint, - LogRequests: true, - }) - if err != nil { - t.Fatalf("unable to create API apiclient") - } - - waitUntilServerStarted(ctx, t, cli) + waitUntilServerStarted(ctx, t, controlClient) // open repository client. rep, err := repo.OpenAPIServer(ctx, &repo.APIServerInfo{ @@ -148,7 +142,7 @@ func testAPIServerRepository(t *testing.T, serverStartArgs []string, useGRPC, al t.Fatal(err) } - logErrorAndIgnore(t, serverapi.Shutdown(ctx, uiUserCLI)) + logErrorAndIgnore(t, serverapi.Shutdown(ctx, controlClient)) // give the server a moment to wind down. time.Sleep(1 * time.Second) @@ -165,23 +159,14 @@ func testAPIServerRepository(t *testing.T, serverStartArgs []string, useGRPC, al "--tls-cert-file", tlsCert, "--server-username", uiUsername, "--server-password", uiPassword, + "--server-control-username", controlUsername, + "--server-control-password", controlPassword, }, serverStartArgs...)...) t.Logf("detected server parameters %#v", sp) - cli, err = apiclient.NewKopiaAPIClient(apiclient.Options{ - BaseURL: sp.baseURL, - Username: "foo@bar", - Password: "baz", - TrustedServerCertificateFingerprint: sp.sha256Fingerprint, - LogRequests: true, - }) - if err != nil { - t.Fatalf("unable to create API apiclient") - } + waitUntilServerStarted(ctx, t, controlClient) - waitUntilServerStarted(ctx, t, cli) - - defer serverapi.Shutdown(ctx, uiUserCLI) + defer serverapi.Shutdown(ctx, controlClient) someLabels := map[string]string{ "type": "snapshot", @@ -261,7 +246,7 @@ func testAPIServerRepository(t *testing.T, serverStartArgs []string, useGRPC, al } // shutdown the server - logErrorAndIgnore(t, serverapi.Shutdown(ctx, uiUserCLI)) + logErrorAndIgnore(t, serverapi.Shutdown(ctx, controlClient)) // open repository client to a dead server, this should fail quickly instead of retrying forever. timer := timetrack.StartTimer() diff --git a/tests/end_to_end_test/server_start_test.go b/tests/end_to_end_test/server_start_test.go index fcde08439..e0595ccd6 100644 --- a/tests/end_to_end_test/server_start_test.go +++ b/tests/end_to_end_test/server_start_test.go @@ -27,15 +27,17 @@ import ( // Pattern in stderr that `kopia server` uses to pass ephemeral data. const ( - serverOutputAddress = "SERVER ADDRESS: " - serverOutputCertSHA256 = "SERVER CERT SHA256: " - serverOutputPassword = "SERVER PASSWORD: " + serverOutputAddress = "SERVER ADDRESS: " + serverOutputCertSHA256 = "SERVER CERT SHA256: " + serverOutputPassword = "SERVER PASSWORD: " + serverOutputControlPassword = "SERVER CONTROL PASSWORD: " ) type serverParameters struct { - baseURL string - sha256Fingerprint string - password string + baseURL string + sha256Fingerprint string + password string + serverControlPassword string } func (s *serverParameters) ProcessOutput(l string) bool { @@ -52,6 +54,10 @@ func (s *serverParameters) ProcessOutput(l string) bool { s.password = strings.TrimPrefix(l, serverOutputPassword) } + if strings.HasPrefix(l, serverOutputControlPassword) { + s.serverControlPassword = strings.TrimPrefix(l, serverOutputControlPassword) + } + return true } @@ -75,6 +81,7 @@ func TestServerStart(t *testing.T) { "--ui", "--address=localhost:0", "--random-password", + "--random-server-control-password", "--tls-generate-cert", "--tls-generate-rsa-key-size=2048", // use shorter key size to speed up generation "--override-hostname=fake-hostname", @@ -92,12 +99,21 @@ func TestServerStart(t *testing.T) { }) require.NoError(t, err) - defer serverapi.Shutdown(ctx, cli) + controlClient, err := apiclient.NewKopiaAPIClient(apiclient.Options{ + BaseURL: sp.baseURL, + Username: "server-control", + Password: sp.serverControlPassword, + TrustedServerCertificateFingerprint: sp.sha256Fingerprint, + LogRequests: true, + }) + require.NoError(t, err) - waitUntilServerStarted(ctx, t, cli) + defer serverapi.Shutdown(ctx, controlClient) + + waitUntilServerStarted(ctx, t, controlClient) verifyUIServedWithCorrectTitle(t, cli, sp) - st := verifyServerConnected(t, cli, true) + st := verifyServerConnected(t, controlClient, true) require.Equal(t, "filesystem", st.Storage) limits, err := serverapi.GetThrottlingLimits(ctx, cli) @@ -205,6 +221,7 @@ func TestServerCreateAndConnectViaAPI(t *testing.T) { e.RunAndProcessStderr(t, sp.ProcessOutput, "server", "start", "--ui", "--address=localhost:0", "--random-password", + "--random-server-control-password", "--tls-generate-cert", "--tls-generate-rsa-key-size=2048", // use shorter key size to speed up generation, ) @@ -218,10 +235,19 @@ func TestServerCreateAndConnectViaAPI(t *testing.T) { }) require.NoError(t, err) - defer serverapi.Shutdown(ctx, cli) + controlClient, err := apiclient.NewKopiaAPIClient(apiclient.Options{ + BaseURL: sp.baseURL, + Username: "server-control", + Password: sp.serverControlPassword, + TrustedServerCertificateFingerprint: sp.sha256Fingerprint, + LogRequests: true, + }) + require.NoError(t, err) - waitUntilServerStarted(ctx, t, cli) - verifyServerConnected(t, cli, false) + defer serverapi.Shutdown(ctx, controlClient) + + waitUntilServerStarted(ctx, t, controlClient) + verifyServerConnected(t, controlClient, false) if err = serverapi.CreateRepository(ctx, cli, &serverapi.CreateRepositoryRequest{ ConnectRepositoryRequest: serverapi.ConnectRepositoryRequest{ @@ -232,13 +258,13 @@ func TestServerCreateAndConnectViaAPI(t *testing.T) { t.Fatalf("create error: %v", err) } - verifyServerConnected(t, cli, true) + verifyServerConnected(t, controlClient, true) if err = serverapi.DisconnectFromRepository(ctx, cli); err != nil { t.Fatalf("disconnect error: %v", err) } - verifyServerConnected(t, cli, false) + verifyServerConnected(t, controlClient, false) if err = serverapi.ConnectToRepository(ctx, cli, &serverapi.ConnectRepositoryRequest{ Password: "foofoo", @@ -247,7 +273,7 @@ func TestServerCreateAndConnectViaAPI(t *testing.T) { t.Fatalf("create error: %v", err) } - verifyServerConnected(t, cli, true) + verifyServerConnected(t, controlClient, true) } func TestConnectToExistingRepositoryViaAPI(t *testing.T) { @@ -274,6 +300,7 @@ func TestConnectToExistingRepositoryViaAPI(t *testing.T) { // at this point repository is not connected, start the server e.RunAndProcessStderr(t, sp.ProcessOutput, "server", "start", "--ui", "--address=localhost:0", "--random-password", + "--random-server-control-password", "--tls-generate-cert", "--tls-generate-rsa-key-size=2048", // use shorter key size to speed up generation "--override-hostname=fake-hostname", "--override-username=fake-username") @@ -287,10 +314,19 @@ func TestConnectToExistingRepositoryViaAPI(t *testing.T) { }) require.NoError(t, err) - defer serverapi.Shutdown(ctx, cli) + controlClient, err := apiclient.NewKopiaAPIClient(apiclient.Options{ + BaseURL: sp.baseURL, + Username: "server-control", + Password: sp.serverControlPassword, + TrustedServerCertificateFingerprint: sp.sha256Fingerprint, + LogRequests: true, + }) + require.NoError(t, err) - waitUntilServerStarted(ctx, t, cli) - verifyServerConnected(t, cli, false) + defer serverapi.Shutdown(ctx, controlClient) + + waitUntilServerStarted(ctx, t, controlClient) + verifyServerConnected(t, controlClient, false) if err = serverapi.ConnectToRepository(ctx, cli, &serverapi.ConnectRepositoryRequest{ Password: testenv.TestRepoPassword, @@ -299,7 +335,7 @@ func TestConnectToExistingRepositoryViaAPI(t *testing.T) { t.Fatalf("connect error: %v", err) } - verifyServerConnected(t, cli, true) + verifyServerConnected(t, controlClient, true) si := snapshot.SourceInfo{Host: "fake-hostname", UserName: "fake-username", Path: sharedTestDataDir1}