server: support for server control APIs and tooling (#1644)

This adds new set of APIs `/api/v1/control/*` which can be used to administratively control a running server.

Once the server is started, the administrative user can control it
using CLI commands:

export KOPIA_SERVER_ADDRESS=...
export KOPIA_SERVER_CERT_FINGERPRINT=...
export KOPIA_SERVER_PASSWORD=...

* `kopia server status` - displays status of sources managed by the server
* `kopia server snapshot` - triggers server-side upload of snapshots for managed sources
* `kopia server cancel` - cancels upload of snapshots for managed sources
* `kopia server pause` - pauses scheduled snapshots for managed sources
* `kopia server resume` - resumes scheduled snapshots for managed sources
* `kopia server refresh` - causes server to resynchronize with externally-made changes, such as policies or new sources
* `kopia server flush` - causes server to flush all pending writes
* `kopia server shutdown` - graceful shutdown of the server

Authentication uses new user `server-control` and is disabled
by default. To enable it when starting the server, provide the password
using one of the following methods:

* `--server-control-password`
* `--random-server-control-password`
* `.htpasswd` file
* `KOPIA_SERVER_CONTROL_PASSWORD` environment variable

This change allows us to tighten the API security and remove some
methods that UI user was able to call, but which were not needed.
This commit is contained in:
Jarek Kowalski authored and GitHub committed 2022-01-03 18:48:38 -08:00
1 parent 33b6fb9cda
commit 2e9a57f0b4
23 files changed
+506 -156

No files matched your search

+11 -3
View File
@@ -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();
+35 -20
View File
@@ -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) {
+3 -5
View File
@@ -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")
}
+121
View File
@@ -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)
}
+1 -1
View File
@@ -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{})
}
+5 -7
View File
@@ -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")
}
+1 -1
View File
@@ -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{})
}
+4 -6
View File
@@ -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")
}
+26
View File
@@ -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{})
}
+22
View File
@@ -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")
}
@@ -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
}
+27
View File
@@ -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'.
+10 -2
View File
@@ -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
-45
View File
@@ -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
}
+1 -1
View File
@@ -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
+2
View File
@@ -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=
+33 -2
View File
@@ -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
}
+10
View File
@@ -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
}
+50 -3
View File
@@ -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 {
+4 -4
View File
@@ -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")
}
+4 -7
View File
@@ -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",
@@ -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()
+55 -19
View File
@@ -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}