diff --git a/cli/command_repository_connect_from_config.go b/cli/command_repository_connect_from_config.go index 3055a0af7..73d044ba9 100644 --- a/cli/command_repository_connect_from_config.go +++ b/cli/command_repository_connect_from_config.go @@ -11,16 +11,29 @@ "github.com/kopia/kopia/repo/blob" ) -var connectToStorageFromConfigPath string +var connectFromConfigFile string +var connectFromConfigToken string func connectToStorageFromConfig(ctx context.Context, isNew bool) (blob.Storage, error) { if isNew { return nil, errors.New("not supported") } + if connectFromConfigFile != "" { + return connectToStorageFromConfigFile(ctx) + } + + if connectFromConfigToken != "" { + return connectToStorageFromConfigToken(ctx) + } + + return nil, errors.New("either --file or --token must be provided") +} + +func connectToStorageFromConfigFile(ctx context.Context) (blob.Storage, error) { var cfg repo.LocalConfig - f, err := os.Open(connectToStorageFromConfigPath) + f, err := os.Open(connectFromConfigFile) if err != nil { return nil, errors.Wrap(err, "unable to open config") } @@ -33,12 +46,23 @@ func connectToStorageFromConfig(ctx context.Context, isNew bool) (blob.Storage, return blob.NewStorage(ctx, cfg.Storage) } +func connectToStorageFromConfigToken(ctx context.Context) (blob.Storage, error) { + ci, pass, err := repo.DecodeToken(connectFromConfigToken) + if err != nil { + return nil, errors.Wrap(err, "invalid token") + } + + passwordFromToken = pass + return blob.NewStorage(ctx, ci) +} + func init() { RegisterStorageConnectFlags( "from-config", "the provided configuration file", func(cmd *kingpin.CmdClause) { - cmd.Arg("file", "Path to the configuration file").Required().StringVar(&connectToStorageFromConfigPath) + cmd.Flag("file", "Path to the configuration file").StringVar(&connectFromConfigFile) + cmd.Flag("token", "Configuration token").StringVar(&connectFromConfigToken) }, connectToStorageFromConfig) } diff --git a/cli/command_repository_status.go b/cli/command_repository_status.go index 8ad39deb8..fbdaf2395 100644 --- a/cli/command_repository_status.go +++ b/cli/command_repository_status.go @@ -14,7 +14,9 @@ ) var ( - statusCommand = repositoryCommands.Command("status", "Display the status of connected repository.") + statusCommand = repositoryCommands.Command("status", "Display the status of connected repository.") + statusReconnectToken = statusCommand.Flag("reconnect-token", "Display reconnect command").Short('t').Bool() + statusReconnectTokenIncludePassword = statusCommand.Flag("reconnect-token-with-password", "Include password in reconnect token").Short('s').Bool() ) func runStatusCommand(ctx context.Context, rep *repo.Repository) error { @@ -26,6 +28,7 @@ func runStatusCommand(ctx context.Context, rep *repo.Repository) error { if cjson, err := json.MarshalIndent(scrubber.ScrubSensitiveData(reflect.ValueOf(ci.Config)).Interface(), " ", " "); err == nil { fmt.Printf("Storage config: %v\n", string(cjson)) } + fmt.Println() fmt.Println() @@ -37,6 +40,24 @@ func runStatusCommand(ctx context.Context, rep *repo.Repository) error { fmt.Printf("Max pack length: %v\n", units.BytesStringBase2(int64(rep.Content.Format.MaxPackSize))) fmt.Printf("Splitter: %v\n", rep.Objects.Format.Splitter) + if *statusReconnectToken { + pass := "" + + if *statusReconnectTokenIncludePassword { + pass = mustGetPasswordFromFlags(false, true) + } + + tok, err := rep.Token(pass) + if err != nil { + return err + } + + fmt.Printf("\nTo reconnect to the repository use:\n\n$ kopia repository connect from-config --token %v\n\n", tok) + if pass != "" { + fmt.Printf("NOTICE: The token printed above can be trivially decoded to reveal the repository password. Do not store it in an unsecured place.\n") + } + } + return nil } diff --git a/cli/password.go b/cli/password.go index c5ebd3e2b..0e929f1e5 100644 --- a/cli/password.go +++ b/cli/password.go @@ -40,7 +40,14 @@ func mustAskForExistingRepositoryPassword() string { return p1 } +var passwordFromToken string + func mustGetPasswordFromFlags(isNew, allowPersistent bool) string { + if passwordFromToken != "" { + // password provided via token + return passwordFromToken + } + if !isNew && allowPersistent { pass, ok := getPersistedPassword(repositoryConfigFileName(), getUserName()) if ok { diff --git a/repo/token.go b/repo/token.go new file mode 100644 index 000000000..505e844c0 --- /dev/null +++ b/repo/token.go @@ -0,0 +1,53 @@ +package repo + +import ( + "encoding/base64" + "encoding/json" + + "github.com/pkg/errors" + + "github.com/kopia/kopia/repo/blob" +) + +type tokenInfo struct { + Version string `json:"version"` + Storage blob.ConnectionInfo `json:"storage"` + Password string `json:"password,omitempty"` +} + +// Token returns an opaque token that contains repository connection information +// and optionally the provided password. +func (r *Repository) Token(password string) (string, error) { + ti := &tokenInfo{ + Version: "1", + Storage: r.Blobs.ConnectionInfo(), + Password: password, + } + + v, err := json.Marshal(ti) + if err != nil { + return "", errors.Wrap(err, "marshal token") + } + + return base64.RawURLEncoding.EncodeToString(v), nil +} + +// DecodeToken decodes the provided token and returns connection info and password if persisted. +func DecodeToken(token string) (blob.ConnectionInfo, string, error) { + t := &tokenInfo{} + + v, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return blob.ConnectionInfo{}, "", errors.New("unable to decode token") + } + + if err := json.Unmarshal(v, t); err != nil { + return blob.ConnectionInfo{}, "", errors.New("unable to decode token") + } + + if t.Version != "1" { + return blob.ConnectionInfo{}, "", errors.New("unsupported token version") + } + + return t.Storage, t.Password, nil +} diff --git a/site/content/docs/Reference/Command-Line/_index.md b/site/content/docs/Reference/Command-Line/_index.md index 6f7e70e7e..ed8b56430 100644 --- a/site/content/docs/Reference/Command-Line/_index.md +++ b/site/content/docs/Reference/Command-Line/_index.md @@ -36,6 +36,25 @@ To disconnect: $ kopia repository disconnect ``` +### Quick Reconnection To Repository + +To quickly reconnect to the repository on another machine, you can use `kopia repository status -t`, which will print quick-reconnect command that encodes all repository connection parameters in an opaque token. You can also embed the repository password, by using `kopia repository status -t -s`. + +Such command can be stored long-term in a secure location, such as password manager for easy recovery. + +```shell +$ kopia repository status -t -s +... + +To reconnect to the repository use: + +$ kopia repository connect from-config --token 03Fy598cYIqbMlNNDz9VLU0K6Pk9alC...BNeazLBdRzP2MHo0MS83zRb + +NOTICE: The token printed above can be trivially decoded to reveal the repository password. Do not store it in an unsecured place. +``` + +> NOTE: Make sure to safeguard the repository token, as it gives full access to the repository to anybody in its posession. + ### Configuration File For each repository connection, Kopia maintains a configuration file and local cache: diff --git a/tests/end_to_end_test/end_to_end_test.go b/tests/end_to_end_test/end_to_end_test.go index d8ccf260e..f2e9f5720 100644 --- a/tests/end_to_end_test/end_to_end_test.go +++ b/tests/end_to_end_test/end_to_end_test.go @@ -121,6 +121,27 @@ func TestEndToEnd(t *testing.T) { e.runAndExpectSuccess(t, "repo", "status") }) + t.Run("ReconnectUsingToken", func(t *testing.T) { + lines := e.runAndExpectSuccess(t, "repo", "status", "-t", "-s") + prefix := "$ kopia " + var reconnectArgs []string + + // look for output line containing the prefix - this will be our reconnect command + for _, l := range lines { + if strings.HasPrefix(l, prefix) { + reconnectArgs = strings.Split(strings.TrimPrefix(l, prefix), " ") + } + } + + if reconnectArgs == nil { + t.Fatalf("can't find reonnect command in kopia repo status output") + } + + e.runAndExpectSuccess(t, "repo", "disconnect") + e.runAndExpectSuccess(t, reconnectArgs...) + e.runAndExpectSuccess(t, "repo", "status") + }) + e.runAndExpectSuccess(t, "snapshot", "create", ".") e.runAndExpectSuccess(t, "snapshot", "list", ".")