Add client engine and desktop CLI that mirror KDBX files
Why: a KeePass application must see an ordinary .kdbx file. A mirror directory of real files gives that on every desktop platform without FUSE, WinFsp or macFUSE, and without cgo. What: internal/client holds the API wrapper, an SSE reader, and a mirror agent whose entire policy is one pure decide() table: adopt, pull, push, force-push, server-wins, restore or manual. Saves are detected by rescan, remote changes arrive over the event stream, and every overwrite of local bytes leaves a conflict copy. cmd/keeporg drives it: login (password, TOTP or browser/OIDC device flow), run, sync, status, plus the group, grant, database and invite administration. Proved: go test ./internal/client covers the decision table and runs a real server in-process for adopt, follow, server-wins, client-wins, manual, read-only restore, server-side rename and event-driven update. Also smoke tested live: built both binaries, bootstrapped a server, logged in, created a database and adopted a local file.
This commit is contained in:
@@ -0,0 +1,572 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cc.tiredsysadmin.keeporg/internal/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
// configPath lets every command share one -config flag.
|
||||||
|
func configFlag(fs *flag.FlagSet) *string {
|
||||||
|
return fs.String("config", client.DefaultConfigPath(), "configuration file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// session loads the configuration and returns a ready API client.
|
||||||
|
func session(path string) (*client.Config, *client.API, error) {
|
||||||
|
cfg, err := client.LoadConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if cfg.Token == "" {
|
||||||
|
return nil, nil, client.ErrNoConfig
|
||||||
|
}
|
||||||
|
return cfg, client.NewAPI(cfg.ServerURL, cfg.Token), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdLogin(ctx context.Context, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("login", flag.ExitOnError)
|
||||||
|
path := configFlag(fs)
|
||||||
|
server := fs.String("server", "", "server base URL, for example https://keeporg.example.com")
|
||||||
|
user := fs.String("user", "", "username (omit with -browser)")
|
||||||
|
dir := fs.String("dir", "", "mirror directory (default "+client.DefaultMirrorDir()+")")
|
||||||
|
browser := fs.String("browser", "", "approve in a browser instead: use -browser=sso for OIDC or -browser=yes")
|
||||||
|
label := fs.String("label", hostLabel(), "label shown in the server's session list")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
cfg, err := client.LoadConfig(*path)
|
||||||
|
if err != nil && !errors.Is(err, client.ErrNoConfig) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = &client.Config{MirrorDir: client.DefaultMirrorDir(), PollSeconds: 2}
|
||||||
|
}
|
||||||
|
if *server != "" {
|
||||||
|
cfg.ServerURL = strings.TrimRight(*server, "/")
|
||||||
|
}
|
||||||
|
if cfg.ServerURL == "" {
|
||||||
|
return errors.New("-server is required the first time you sign in")
|
||||||
|
}
|
||||||
|
if *dir != "" {
|
||||||
|
cfg.MirrorDir = *dir
|
||||||
|
}
|
||||||
|
if cfg.MirrorDir == "" {
|
||||||
|
cfg.MirrorDir = client.DefaultMirrorDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
api := client.NewAPI(cfg.ServerURL, "")
|
||||||
|
var sess *client.Session
|
||||||
|
switch {
|
||||||
|
case *browser != "":
|
||||||
|
sess, err = browserLogin(ctx, api, *label)
|
||||||
|
default:
|
||||||
|
if *user == "" {
|
||||||
|
return errors.New("-user is required, or use -browser=yes")
|
||||||
|
}
|
||||||
|
sess, err = passwordLogin(ctx, api, *user, *label)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.Token, cfg.Username = sess.Token, sess.User.Username
|
||||||
|
if err := cfg.Save(*path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("signed in as %s; mirroring into %s\n", sess.User.Username, cfg.MirrorDir)
|
||||||
|
if !sess.User.TOTPEnabled {
|
||||||
|
fmt.Println("tip: run `keeporg totp` to add a second factor")
|
||||||
|
}
|
||||||
|
fmt.Println("start the sync agent with `keeporg run`")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// passwordLogin asks for a password, and for a second factor if the server
|
||||||
|
// says one is needed.
|
||||||
|
func passwordLogin(ctx context.Context, api *client.API, user, label string) (*client.Session, error) {
|
||||||
|
password, err := readPassword(fmt.Sprintf("Password for %s: ", user))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sess, err := api.Login(ctx, user, password, "", label)
|
||||||
|
if client.Code(err) == "totp_required" || client.Code(err) == "totp_invalid" {
|
||||||
|
code, cerr := prompt("One-time code: ")
|
||||||
|
if cerr != nil {
|
||||||
|
return nil, cerr
|
||||||
|
}
|
||||||
|
return api.Login(ctx, user, password, code, label)
|
||||||
|
}
|
||||||
|
return sess, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// browserLogin runs the device flow, which is also how OIDC sign-in works.
|
||||||
|
func browserLogin(ctx context.Context, api *client.API, label string) (*client.Session, error) {
|
||||||
|
start, err := api.DeviceStart(ctx, label)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fmt.Printf("Open this page and approve the code %s:\n\n %s\n\n",
|
||||||
|
start.UserCode, start.VerificationURL)
|
||||||
|
openBrowser(start.VerificationURL)
|
||||||
|
fmt.Print("waiting for approval")
|
||||||
|
interval := time.Duration(max(start.Interval, 1)) * time.Second
|
||||||
|
deadline := time.Now().Add(time.Duration(max(start.ExpiresIn, 60)) * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(interval):
|
||||||
|
}
|
||||||
|
sess, err := api.DevicePoll(ctx, start.DeviceCode)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if sess != nil {
|
||||||
|
fmt.Println(" approved")
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
fmt.Print(".")
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
return nil, errors.New("the approval window expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdLogout(ctx context.Context, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("logout", flag.ExitOnError)
|
||||||
|
path := configFlag(fs)
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
cfg, api, err := session(*path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := api.Logout(ctx); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "keeporg: revoking on the server failed:", err)
|
||||||
|
}
|
||||||
|
cfg.Token = ""
|
||||||
|
if err := cfg.Save(*path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("signed out; the mirrored files were left in place")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdRun(ctx context.Context, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("run", flag.ExitOnError)
|
||||||
|
path := configFlag(fs)
|
||||||
|
quiet := fs.Bool("quiet", false, "log nothing but errors")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
cfg, api, err := session(*path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := os.Stderr
|
||||||
|
lg := log.New(out, "", log.LstdFlags)
|
||||||
|
if *quiet {
|
||||||
|
lg = log.New(io.Discard, "", 0)
|
||||||
|
}
|
||||||
|
mir, err := client.NewMirror(api, cfg.MirrorDir, lg,
|
||||||
|
time.Duration(cfg.PollSeconds)*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "mirroring %s databases into %s (press Ctrl-C to stop)\n",
|
||||||
|
cfg.ServerURL, cfg.MirrorDir)
|
||||||
|
err = mir.Run(ctx)
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdSync(ctx context.Context, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("sync", flag.ExitOnError)
|
||||||
|
path := configFlag(fs)
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
cfg, api, err := session(*path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mir, err := client.NewMirror(api, cfg.MirrorDir, log.New(os.Stderr, "", 0),
|
||||||
|
time.Duration(cfg.PollSeconds)*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := mir.SyncAll(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printStatus(mir.Status())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdStatus(ctx context.Context, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("status", flag.ExitOnError)
|
||||||
|
path := configFlag(fs)
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
cfg, api, err := session(*path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("server %s\nuser %s\nmirror %s\n\n", cfg.ServerURL, cfg.Username, cfg.MirrorDir)
|
||||||
|
dbs, err := api.Databases(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "DATABASE\tACCESS\tSTRATEGY\tVERSION\tUPDATED BY\tUPDATED")
|
||||||
|
for _, d := range dbs {
|
||||||
|
when := "never"
|
||||||
|
if d.Version > 0 {
|
||||||
|
when = d.UpdatedAt.Local().Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\t%s\n", d.Name, d.Perm, d.MergeStrategy, d.Version, d.UpdatedBy, when)
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func printStatus(st []client.Status) {
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "DATABASE\tFILE\tVERSION\tSTATE\tNOTE")
|
||||||
|
for _, s := range st {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\n", s.Name, s.File, s.Version, s.State, s.Message)
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdTOTP(ctx context.Context, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("totp", flag.ExitOnError)
|
||||||
|
path := configFlag(fs)
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
_, api, err := session(*path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sec, uri, err := api.EnrollTOTP(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("Add this secret to your authenticator app:\n\n secret %s\n uri %s\n\n", sec, uri)
|
||||||
|
code, err := prompt("Enter the code it shows: ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := api.ConfirmTOTP(ctx, code); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("second factor enabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdWhoami(ctx context.Context, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("whoami", flag.ExitOnError)
|
||||||
|
path := configFlag(fs)
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
_, api, err := session(*path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
me, err := api.Whoami(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%s", me.Username)
|
||||||
|
if me.ServerAdmin {
|
||||||
|
fmt.Print(" (server administrator)")
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
for _, g := range me.Groups {
|
||||||
|
fmt.Printf(" %-24s %-8s %s\n", g.Name, g.Role, g.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdGroups(ctx context.Context, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("groups", flag.ExitOnError)
|
||||||
|
path := configFlag(fs)
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
_, api, err := session(*path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
groups, err := api.Groups(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "ID\tNAME\tPARENT\tMEMBERS\tYOU ADMINISTER")
|
||||||
|
for _, g := range groups {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%v\n", g.ID, g.Name, g.ParentID, g.Members, g.Admin)
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdGroup(ctx context.Context, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("group", flag.ExitOnError)
|
||||||
|
path := configFlag(fs)
|
||||||
|
create := fs.String("create", "", "create a group with this name")
|
||||||
|
parent := fs.String("parent", "", "parent group id (empty creates a root group)")
|
||||||
|
del := fs.String("delete", "", "delete the group with this id")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
_, api, err := session(*path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case *create != "":
|
||||||
|
g, err := api.CreateGroup(ctx, *create, *parent)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("created group %s (%s)\n", g.Name, g.ID)
|
||||||
|
case *del != "":
|
||||||
|
if err := api.DeleteGroup(ctx, *del); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("deleted")
|
||||||
|
default:
|
||||||
|
return errors.New("use -create <name> or -delete <id>")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdMember(ctx context.Context, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("member", flag.ExitOnError)
|
||||||
|
path := configFlag(fs)
|
||||||
|
group := fs.String("group", "", "group id")
|
||||||
|
add := fs.String("add", "", "username to add or update")
|
||||||
|
role := fs.String("role", "member", "member or admin")
|
||||||
|
remove := fs.String("remove", "", "username to remove")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
_, api, err := session(*path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *group == "" {
|
||||||
|
return errors.New("-group is required")
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case *add != "":
|
||||||
|
if err := api.SetMember(ctx, *group, *add, *role); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%s is now %s of %s\n", *add, *role, *group)
|
||||||
|
case *remove != "":
|
||||||
|
if err := api.RemoveMember(ctx, *group, *remove); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("removed")
|
||||||
|
default:
|
||||||
|
return errors.New("use -add <user> or -remove <user>")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdDB(ctx context.Context, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("db", flag.ExitOnError)
|
||||||
|
path := configFlag(fs)
|
||||||
|
create := fs.String("create", "", "create a database with this name")
|
||||||
|
group := fs.String("group", "", "owning group id, required with -create")
|
||||||
|
strategy := fs.String("strategy", "", "merge strategy: server-wins, client-wins or manual")
|
||||||
|
id := fs.String("id", "", "database id, for -strategy, -rename or -delete")
|
||||||
|
rename := fs.String("rename", "", "new name for -id")
|
||||||
|
del := fs.Bool("delete", false, "delete the database given by -id")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
_, api, err := session(*path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case *create != "":
|
||||||
|
if *group == "" {
|
||||||
|
return errors.New("-group is required with -create")
|
||||||
|
}
|
||||||
|
db, err := api.CreateDatabase(ctx, *create, *group, *strategy)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("created database %s (%s), strategy %s\n", db.Name, db.ID, db.MergeStrategy)
|
||||||
|
fmt.Println("create the file itself by saving a new KDBX database as",
|
||||||
|
db.Name+".kdbx", "in your mirror directory")
|
||||||
|
case *id != "" && *rename != "":
|
||||||
|
db, err := api.RenameDatabase(ctx, *id, *rename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("renamed to %s\n", db.Name)
|
||||||
|
case *id != "" && *strategy != "":
|
||||||
|
db, err := api.SetStrategy(ctx, *id, *strategy)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%s now uses %s\n", db.Name, db.MergeStrategy)
|
||||||
|
case *id != "" && *del:
|
||||||
|
if err := api.DeleteDatabase(ctx, *id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("deleted on the server; local files were left in place")
|
||||||
|
default:
|
||||||
|
return errors.New("use -create <name> -group <id>, or -id <id> with -strategy, -rename or -delete")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdGrant(ctx context.Context, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("grant", flag.ExitOnError)
|
||||||
|
path := configFlag(fs)
|
||||||
|
group := fs.String("group", "", "group id")
|
||||||
|
db := fs.String("db", "", "database id")
|
||||||
|
perm := fs.String("perm", "", "read, write or admin")
|
||||||
|
revoke := fs.Bool("revoke", false, "remove the grant instead")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
_, api, err := session(*path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *group == "" || *db == "" {
|
||||||
|
return errors.New("-group and -db are required")
|
||||||
|
}
|
||||||
|
if *revoke {
|
||||||
|
if err := api.DeleteGrant(ctx, *group, *db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("revoked")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if *perm == "" {
|
||||||
|
return errors.New("-perm is required")
|
||||||
|
}
|
||||||
|
if err := api.SetGrant(ctx, *group, *db, *perm); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("group %s now has %s access\n", *group, *perm)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdInvite(ctx context.Context, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("invite", flag.ExitOnError)
|
||||||
|
path := configFlag(fs)
|
||||||
|
group := fs.String("group", "", "group the invited user joins")
|
||||||
|
role := fs.String("role", "member", "member or admin")
|
||||||
|
hours := fs.Int("hours", 168, "how long the link stays valid")
|
||||||
|
uses := fs.Int("uses", 1, "how many accounts may be created with it")
|
||||||
|
list := fs.Bool("list", false, "list invites instead of creating one")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
_, api, err := session(*path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *list {
|
||||||
|
invites, err := api.Invites(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "URL\tGROUP\tROLE\tUSES\tEXPIRES")
|
||||||
|
for _, i := range invites {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%d/%d\t%s\n", i.URL, i.GroupID, i.Role, i.Uses, i.MaxUses,
|
||||||
|
i.ExpiresAt.Local().Format("2006-01-02 15:04"))
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
}
|
||||||
|
inv, err := api.CreateInvite(ctx, *group, *role, *hours, *uses)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("send this link:\n\n %s\n\nvalid until %s, %d use(s)\n",
|
||||||
|
inv.URL, inv.ExpiresAt.Local().Format("2006-01-02 15:04"), inv.MaxUses)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdUsers(ctx context.Context, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("users", flag.ExitOnError)
|
||||||
|
path := configFlag(fs)
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
_, api, err := session(*path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
users, err := api.Users(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "ID\tUSERNAME\tEMAIL\tADMIN\t2FA")
|
||||||
|
for _, u := range users {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%v\t%v\n", u.ID, u.Username, u.Email, u.ServerAdmin, u.TOTPEnabled)
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- terminal helpers ----
|
||||||
|
|
||||||
|
func prompt(label string) (string, error) {
|
||||||
|
fmt.Print(label)
|
||||||
|
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
|
return strings.TrimSpace(line), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPassword takes a password without echoing it. It prefers the
|
||||||
|
// environment and a file so that scripts never need a terminal at all.
|
||||||
|
func readPassword(label string) (string, error) {
|
||||||
|
if v := os.Getenv("KEEPORG_PASSWORD"); v != "" {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
if f := os.Getenv("KEEPORG_PASSWORD_FILE"); f != "" {
|
||||||
|
b, err := os.ReadFile(f)
|
||||||
|
return strings.TrimSpace(string(b)), err
|
||||||
|
}
|
||||||
|
restore, hidden := hideInput()
|
||||||
|
defer restore()
|
||||||
|
fmt.Print(label)
|
||||||
|
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
|
if hidden {
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(line), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// hideInput turns terminal echo off where a terminal and stty are available.
|
||||||
|
// Elsewhere the password is typed visibly, which is stated by the prompt.
|
||||||
|
func hideInput() (restore func(), hidden bool) {
|
||||||
|
if _, err := exec.LookPath("stty"); err != nil {
|
||||||
|
return func() {}, false
|
||||||
|
}
|
||||||
|
cmd := exec.Command("stty", "-echo")
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return func() {}, false
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
c := exec.Command("stty", "echo")
|
||||||
|
c.Stdin = os.Stdin
|
||||||
|
_ = c.Run()
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// openBrowser is best effort: the URL is always printed as well.
|
||||||
|
func openBrowser(url string) {
|
||||||
|
for _, c := range [][]string{{"xdg-open", url}, {"open", url},
|
||||||
|
{"rundll32", "url.dll,FileProtocolHandler", url}} {
|
||||||
|
if _, err := exec.LookPath(c[0]); err == nil {
|
||||||
|
_ = exec.Command(c[0], c[1:]...).Start()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostLabel() string {
|
||||||
|
h, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
return "keeporg client"
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Command keeporg is the KeepOrg desktop client.
|
||||||
|
//
|
||||||
|
// It mirrors every KDBX database assigned to you into one directory as real
|
||||||
|
// files, pushes each save to the server and applies what other clients push.
|
||||||
|
// Point KeePassXC, KeePass2 or any compatible application at those files.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"cc.tiredsysadmin.keeporg/internal/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is stamped at build time from versions.mk.
|
||||||
|
var Version = "dev"
|
||||||
|
|
||||||
|
type command struct {
|
||||||
|
name string
|
||||||
|
help string
|
||||||
|
run func(ctx context.Context, args []string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cmds := []command{
|
||||||
|
{"login", "sign in to a server and choose the mirror directory", cmdLogin},
|
||||||
|
{"logout", "revoke this device's session", cmdLogout},
|
||||||
|
{"run", "keep the mirror in sync until stopped", cmdRun},
|
||||||
|
{"sync", "reconcile once and exit", cmdSync},
|
||||||
|
{"status", "show each mirrored database", cmdStatus},
|
||||||
|
{"totp", "enrol a second factor for this account", cmdTOTP},
|
||||||
|
{"whoami", "show the signed-in account and its groups", cmdWhoami},
|
||||||
|
{"groups", "list groups you can see", cmdGroups},
|
||||||
|
{"group", "create or delete a group", cmdGroup},
|
||||||
|
{"member", "add or remove a group member", cmdMember},
|
||||||
|
{"db", "create, rename, re-strategy or delete a database", cmdDB},
|
||||||
|
{"grant", "give or take a group's access to a database", cmdGrant},
|
||||||
|
{"invite", "create or list invite links", cmdInvite},
|
||||||
|
{"users", "list accounts you administer", cmdUsers},
|
||||||
|
{"version", "print the version", nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
usage(cmds)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
name := os.Args[1]
|
||||||
|
if name == "version" || name == "-version" || name == "--version" {
|
||||||
|
fmt.Println("keeporg", Version)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, c := range cmds {
|
||||||
|
if c.name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := c.run(ctx, os.Args[2:]); err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "keeporg:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "keeporg: unknown command %q\n\n", name)
|
||||||
|
usage(cmds)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func usage(cmds []command) {
|
||||||
|
fmt.Fprintf(os.Stderr, "keeporg %s - KeePass database synchronization\n\nusage: keeporg <command> [flags]\n\n", Version)
|
||||||
|
w := tabwriter.NewWriter(os.Stderr, 0, 2, 2, ' ', 0)
|
||||||
|
for _, c := range cmds {
|
||||||
|
fmt.Fprintf(w, " %s\t%s\n", c.name, c.help)
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
fmt.Fprintf(os.Stderr, "\nRun `keeporg <command> -h` for a command's flags.\n"+
|
||||||
|
"Configuration lives in %s\n", client.DefaultConfigPath())
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Group is a node of the server's group tree as the client sees it.
|
||||||
|
type Group struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ParentID string `json:"parent_id"`
|
||||||
|
Admin bool `json:"admin"`
|
||||||
|
Members int `json:"members"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invite is a registration link.
|
||||||
|
type Invite struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
GroupID string `json:"group_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Uses int `json:"uses"`
|
||||||
|
MaxUses int `json:"max_uses"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// User is an account as an administrator sees it.
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
ServerAdmin bool `json:"server_admin"`
|
||||||
|
TOTPEnabled bool `json:"totp_enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identity is the answer to "who am I and where do I belong".
|
||||||
|
type Identity struct {
|
||||||
|
User
|
||||||
|
Groups []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
} `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) Whoami(ctx context.Context) (*Identity, error) {
|
||||||
|
var out Identity
|
||||||
|
return &out, a.do(ctx, http.MethodGet, "/api/v1/auth/whoami", nil, &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) Groups(ctx context.Context) ([]Group, error) {
|
||||||
|
var out struct {
|
||||||
|
Groups []Group `json:"groups"`
|
||||||
|
}
|
||||||
|
err := a.do(ctx, http.MethodGet, "/api/v1/groups", nil, &out)
|
||||||
|
return out.Groups, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) CreateGroup(ctx context.Context, name, parentID string) (*Group, error) {
|
||||||
|
var out Group
|
||||||
|
return &out, a.do(ctx, http.MethodPost, "/api/v1/groups",
|
||||||
|
map[string]string{"name": name, "parent_id": parentID}, &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) DeleteGroup(ctx context.Context, id string) error {
|
||||||
|
return a.do(ctx, http.MethodDelete, "/api/v1/groups/"+url.PathEscape(id), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) SetMember(ctx context.Context, groupID, username, role string) error {
|
||||||
|
return a.do(ctx, http.MethodPost,
|
||||||
|
"/api/v1/groups/"+url.PathEscape(groupID)+"/members",
|
||||||
|
map[string]string{"username": username, "role": role}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) RemoveMember(ctx context.Context, groupID, user string) error {
|
||||||
|
return a.do(ctx, http.MethodDelete,
|
||||||
|
"/api/v1/groups/"+url.PathEscape(groupID)+"/members/"+url.PathEscape(user), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) CreateDatabase(ctx context.Context, name, groupID, strategy string) (*Database, error) {
|
||||||
|
body := map[string]string{"name": name, "owner_group_id": groupID}
|
||||||
|
if strategy != "" {
|
||||||
|
body["merge_strategy"] = strategy
|
||||||
|
}
|
||||||
|
var out Database
|
||||||
|
return &out, a.do(ctx, http.MethodPost, "/api/v1/databases", body, &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStrategy changes the merge strategy of a database. Only an
|
||||||
|
// administrator of the owning group may do this.
|
||||||
|
func (a *API) SetStrategy(ctx context.Context, dbID, strategy string) (*Database, error) {
|
||||||
|
var out Database
|
||||||
|
return &out, a.do(ctx, http.MethodPatch, "/api/v1/databases/"+url.PathEscape(dbID),
|
||||||
|
map[string]string{"merge_strategy": strategy}, &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) RenameDatabase(ctx context.Context, dbID, name string) (*Database, error) {
|
||||||
|
var out Database
|
||||||
|
return &out, a.do(ctx, http.MethodPatch, "/api/v1/databases/"+url.PathEscape(dbID),
|
||||||
|
map[string]string{"name": name}, &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) DeleteDatabase(ctx context.Context, dbID string) error {
|
||||||
|
return a.do(ctx, http.MethodDelete, "/api/v1/databases/"+url.PathEscape(dbID), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) SetGrant(ctx context.Context, groupID, dbID, perm string) error {
|
||||||
|
return a.do(ctx, http.MethodPost, "/api/v1/grants",
|
||||||
|
map[string]string{"group_id": groupID, "database_id": dbID, "perm": perm}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) DeleteGrant(ctx context.Context, groupID, dbID string) error {
|
||||||
|
q := url.Values{"group_id": {groupID}, "database_id": {dbID}}
|
||||||
|
return a.do(ctx, http.MethodDelete, "/api/v1/grants?"+q.Encode(), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) CreateInvite(ctx context.Context, groupID, role string, ttlHours, maxUses int) (*Invite, error) {
|
||||||
|
var out Invite
|
||||||
|
return &out, a.do(ctx, http.MethodPost, "/api/v1/invites", map[string]any{
|
||||||
|
"group_id": groupID, "role": role, "ttl_hours": ttlHours, "max_uses": maxUses}, &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) Invites(ctx context.Context) ([]Invite, error) {
|
||||||
|
var out struct {
|
||||||
|
Invites []Invite `json:"invites"`
|
||||||
|
}
|
||||||
|
err := a.do(ctx, http.MethodGet, "/api/v1/invites", nil, &out)
|
||||||
|
return out.Invites, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) Users(ctx context.Context) ([]User, error) {
|
||||||
|
var out struct {
|
||||||
|
Users []User `json:"users"`
|
||||||
|
}
|
||||||
|
err := a.do(ctx, http.MethodGet, "/api/v1/users", nil, &out)
|
||||||
|
return out.Users, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnrollTOTP starts second-factor enrolment and returns the shared secret
|
||||||
|
// and its otpauth URI.
|
||||||
|
func (a *API) EnrollTOTP(ctx context.Context) (secret, uri string, err error) {
|
||||||
|
var out struct {
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
URI string `json:"uri"`
|
||||||
|
}
|
||||||
|
err = a.do(ctx, http.MethodPost, "/api/v1/auth/totp/enroll", nil, &out)
|
||||||
|
return out.Secret, out.URI, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmTOTP completes enrolment by proving the secret was stored.
|
||||||
|
func (a *API) ConfirmTOTP(ctx context.Context, code string) error {
|
||||||
|
return a.do(ctx, http.MethodPost, "/api/v1/auth/totp/confirm",
|
||||||
|
map[string]string{"code": code}, nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
// Package client is the shared client engine: one API wrapper and one
|
||||||
|
// synchronization agent, used by the desktop daemon and the admin CLI.
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// API talks to one KeepOrg server as one user.
|
||||||
|
type API struct {
|
||||||
|
BaseURL string
|
||||||
|
Token string
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAPI returns a client for a server base URL.
|
||||||
|
func NewAPI(baseURL, token string) *API {
|
||||||
|
return &API{
|
||||||
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
Token: token,
|
||||||
|
HTTP: &http.Client{Timeout: 60 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error is a structured failure returned by the server.
|
||||||
|
type Error struct {
|
||||||
|
Status int `json:"-"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"error"`
|
||||||
|
// Conflict detail, present when Code is "conflict".
|
||||||
|
CurrentVersion int64 `json:"current_version"`
|
||||||
|
MergeStrategy string `json:"merge_strategy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string {
|
||||||
|
if e.Message == "" {
|
||||||
|
return fmt.Sprintf("server returned %d", e.Status)
|
||||||
|
}
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConflict reports whether err is a rejected push.
|
||||||
|
func IsConflict(err error) bool {
|
||||||
|
var e *Error
|
||||||
|
return errors.As(err, &e) && (e.Code == "conflict" || e.Code == "force_not_allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code returns the server's error code for err, or "".
|
||||||
|
func Code(err error) string {
|
||||||
|
var e *Error
|
||||||
|
if errors.As(err, &e) {
|
||||||
|
return e.Code
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database is one database as the client sees it.
|
||||||
|
type Database struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
OwnerGroupID string `json:"owner_group_id"`
|
||||||
|
Perm string `json:"perm"`
|
||||||
|
MergeStrategy string `json:"merge_strategy"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
UpdatedBy string `json:"updated_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanWrite reports whether the user may push to this database.
|
||||||
|
func (d Database) CanWrite() bool { return d.Perm == "write" || d.Perm == "admin" }
|
||||||
|
|
||||||
|
// Session is the result of a successful login.
|
||||||
|
type Session struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
ServerAdmin bool `json:"server_admin"`
|
||||||
|
TOTPEnabled bool `json:"totp_enabled"`
|
||||||
|
} `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceStart is a pending browser-approved login.
|
||||||
|
type DeviceStart struct {
|
||||||
|
DeviceCode string `json:"device_code"`
|
||||||
|
UserCode string `json:"user_code"`
|
||||||
|
VerificationURL string `json:"verification_url"`
|
||||||
|
Interval int `json:"interval"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushResult reports what the server did with an accepted push.
|
||||||
|
type PushResult struct {
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
Unchanged bool `json:"unchanged"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login exchanges a password and optional second factor for a session.
|
||||||
|
func (a *API) Login(ctx context.Context, user, password, totp, label string) (*Session, error) {
|
||||||
|
var out Session
|
||||||
|
err := a.do(ctx, http.MethodPost, "/api/v1/auth/login", map[string]string{
|
||||||
|
"username": user, "password": password, "totp": totp, "label": label}, &out)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceStart opens a browser-approved login, used for OIDC and for devices
|
||||||
|
// without a comfortable keyboard.
|
||||||
|
func (a *API) DeviceStart(ctx context.Context, label string) (*DeviceStart, error) {
|
||||||
|
var out DeviceStart
|
||||||
|
if err := a.do(ctx, http.MethodPost, "/api/v1/auth/device/start",
|
||||||
|
map[string]string{"label": label}, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DevicePoll checks whether a browser has approved the login yet. It returns
|
||||||
|
// a nil session while the request is still pending.
|
||||||
|
func (a *API) DevicePoll(ctx context.Context, deviceCode string) (*Session, error) {
|
||||||
|
var out Session
|
||||||
|
err := a.do(ctx, http.MethodPost, "/api/v1/auth/device/poll",
|
||||||
|
map[string]string{"device_code": deviceCode}, &out)
|
||||||
|
var e *Error
|
||||||
|
switch {
|
||||||
|
case errors.As(err, &e) && e.Status == http.StatusAccepted:
|
||||||
|
return nil, nil
|
||||||
|
case err != nil:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout revokes this client's session.
|
||||||
|
func (a *API) Logout(ctx context.Context) error {
|
||||||
|
return a.do(ctx, http.MethodPost, "/api/v1/auth/logout", nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Databases lists everything assigned to the user.
|
||||||
|
func (a *API) Databases(ctx context.Context) ([]Database, error) {
|
||||||
|
var out struct {
|
||||||
|
Databases []Database `json:"databases"`
|
||||||
|
}
|
||||||
|
if err := a.do(ctx, http.MethodGet, "/api/v1/databases", nil, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out.Databases, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull downloads the current bytes of a database along with their version.
|
||||||
|
// A database that has never been written returns no bytes and version 0.
|
||||||
|
func (a *API) Pull(ctx context.Context, dbID string) ([]byte, int64, error) {
|
||||||
|
req, err := a.request(ctx, http.MethodGet, "/api/v1/databases/"+url.PathEscape(dbID)+"/blob", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
resp, err := a.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusNoContent {
|
||||||
|
return nil, 0, nil
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, 0, decodeError(resp)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
v, _ := strconv.ParseInt(strings.Trim(resp.Header.Get("ETag"), `"`), 10, 64)
|
||||||
|
return data, v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push uploads new bytes. baseVersion is the version the edit was based on;
|
||||||
|
// force is only accepted by a client-wins database.
|
||||||
|
func (a *API) Push(ctx context.Context, dbID string, baseVersion int64, data []byte, force bool) (*PushResult, error) {
|
||||||
|
req, err := a.request(ctx, http.MethodPut,
|
||||||
|
"/api/v1/databases/"+url.PathEscape(dbID)+"/blob", bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if force {
|
||||||
|
req.Header.Set("If-Match", "*")
|
||||||
|
} else {
|
||||||
|
req.Header.Set("If-Match", strconv.FormatInt(baseVersion, 10))
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
|
resp, err := a.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, decodeError(resp)
|
||||||
|
}
|
||||||
|
var out PushResult
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event is a push notification from the server.
|
||||||
|
type Event struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
DatabaseID string `json:"database_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
By string `json:"by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events streams server notifications until ctx is done or the stream breaks.
|
||||||
|
// The caller reconnects; losing events is safe because every notification
|
||||||
|
// only ever triggers a full reconcile.
|
||||||
|
func (a *API) Events(ctx context.Context, onEvent func(Event)) error {
|
||||||
|
// The token travels as a query parameter because an event stream has no
|
||||||
|
// place to put a header on reconnect in every client runtime.
|
||||||
|
u := a.BaseURL + "/api/v1/events?access_token=" + url.QueryEscape(a.Token)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "text/event-stream")
|
||||||
|
// A streaming response must not be cut off by the client timeout.
|
||||||
|
stream := &http.Client{Transport: a.transport()}
|
||||||
|
resp, err := stream.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return decodeError(resp)
|
||||||
|
}
|
||||||
|
dec := newSSEDecoder(resp.Body)
|
||||||
|
for {
|
||||||
|
ev, err := dec.next()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ev == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
onEvent(*ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) transport() http.RoundTripper {
|
||||||
|
if a.HTTP != nil && a.HTTP.Transport != nil {
|
||||||
|
return a.HTTP.Transport
|
||||||
|
}
|
||||||
|
return http.DefaultTransport
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) request(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, a.BaseURL+path, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if a.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+a.Token)
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// do sends a JSON request and decodes a JSON response. A non-2xx status
|
||||||
|
// always becomes an *Error.
|
||||||
|
func (a *API) do(ctx context.Context, method, path string, in, out any) error {
|
||||||
|
var body io.Reader
|
||||||
|
if in != nil {
|
||||||
|
b, err := json.Marshal(in)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
req, err := a.request(ctx, method, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if in != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
resp, err := a.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||||
|
return decodeError(resp)
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusAccepted {
|
||||||
|
return &Error{Status: resp.StatusCode, Code: "pending"}
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeError(resp *http.Response) error {
|
||||||
|
e := &Error{Status: resp.StatusCode}
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
||||||
|
if len(b) > 0 && json.Unmarshal(b, e) == nil && e.Message != "" {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
e.Message = strings.TrimSpace(string(b))
|
||||||
|
if e.Message == "" {
|
||||||
|
e.Message = resp.Status
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is what a client remembers between runs. The session token lives
|
||||||
|
// here; the KDBX master passwords never do, because the client never opens a
|
||||||
|
// database, it only moves bytes.
|
||||||
|
type Config struct {
|
||||||
|
ServerURL string `json:"server_url"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
MirrorDir string `json:"mirror_dir"`
|
||||||
|
// PollSeconds is how often the mirror is rescanned for local saves.
|
||||||
|
PollSeconds int `json:"poll_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNoConfig means the client has not been logged in yet.
|
||||||
|
var ErrNoConfig = errors.New("not logged in: run `keeporg login` first")
|
||||||
|
|
||||||
|
// DefaultConfigPath is the per-user configuration path on this platform.
|
||||||
|
func DefaultConfigPath() string {
|
||||||
|
dir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return filepath.Join(".", ".keeporg", "config.json")
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "keeporg", "config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultMirrorDir is where databases are mirrored unless told otherwise.
|
||||||
|
func DefaultMirrorDir() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "keeporg"
|
||||||
|
}
|
||||||
|
return filepath.Join(home, "KeepOrg")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfig reads a configuration file.
|
||||||
|
func LoadConfig(path string) (*Config, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, ErrNoConfig
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var c Config
|
||||||
|
if err := json.Unmarshal(b, &c); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.MirrorDir == "" {
|
||||||
|
c.MirrorDir = DefaultMirrorDir()
|
||||||
|
}
|
||||||
|
if c.PollSeconds <= 0 {
|
||||||
|
c.PollSeconds = 2
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes the configuration with owner-only permissions, since it holds
|
||||||
|
// a bearer token.
|
||||||
|
func (c *Config) Save(path string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(c, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
// action is what the agent does to one database on one pass.
|
||||||
|
type action int
|
||||||
|
|
||||||
|
const (
|
||||||
|
actNothing action = iota
|
||||||
|
// actPull writes the server copy over the local file.
|
||||||
|
actPull
|
||||||
|
// actPush uploads the local file against the version it was based on.
|
||||||
|
actPush
|
||||||
|
// actForcePush uploads the local file and overwrites a newer server copy.
|
||||||
|
actForcePush
|
||||||
|
// actServerWins keeps the local file as a conflict copy, then pulls.
|
||||||
|
actServerWins
|
||||||
|
// actRestore is actServerWins forced on the client by read-only access.
|
||||||
|
actRestore
|
||||||
|
// actManual writes the server copy beside the local file and waits for a
|
||||||
|
// human.
|
||||||
|
actManual
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a action) String() string {
|
||||||
|
switch a {
|
||||||
|
case actPull:
|
||||||
|
return "pull"
|
||||||
|
case actPush:
|
||||||
|
return "push"
|
||||||
|
case actForcePush:
|
||||||
|
return "force-push"
|
||||||
|
case actServerWins:
|
||||||
|
return "server-wins"
|
||||||
|
case actRestore:
|
||||||
|
return "restore"
|
||||||
|
case actManual:
|
||||||
|
return "manual-conflict"
|
||||||
|
}
|
||||||
|
return "nothing"
|
||||||
|
}
|
||||||
|
|
||||||
|
// situation is everything the agent knows about one database at one moment.
|
||||||
|
type situation struct {
|
||||||
|
LocalExists bool
|
||||||
|
// LocalSHA is the digest of the file on disk now.
|
||||||
|
LocalSHA string
|
||||||
|
// SyncedSHA and SyncedVersion are what this client last agreed with the
|
||||||
|
// server. An empty SyncedSHA means this client has never synced the file.
|
||||||
|
SyncedSHA string
|
||||||
|
SyncedVersion int64
|
||||||
|
RemoteSHA string
|
||||||
|
RemoteVersion int64
|
||||||
|
CanWrite bool
|
||||||
|
Strategy string
|
||||||
|
// ConflictSeen is set when this exact manual conflict was already
|
||||||
|
// reported, so it is not reported again on every pass.
|
||||||
|
ConflictSeen bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// decide is the whole client-side synchronization policy, kept pure so the
|
||||||
|
// table of cases can be read and tested in one place.
|
||||||
|
func decide(s situation) action {
|
||||||
|
localChanged := s.LocalExists && s.LocalSHA != s.SyncedSHA
|
||||||
|
remoteChanged := s.RemoteVersion != s.SyncedVersion || s.RemoteSHA != s.SyncedSHA
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case !s.LocalExists:
|
||||||
|
if s.RemoteVersion > 0 {
|
||||||
|
return actPull
|
||||||
|
}
|
||||||
|
return actNothing
|
||||||
|
|
||||||
|
case localChanged && !remoteChanged:
|
||||||
|
if !s.CanWrite {
|
||||||
|
return actRestore
|
||||||
|
}
|
||||||
|
return actPush
|
||||||
|
|
||||||
|
case !localChanged && remoteChanged:
|
||||||
|
return actPull
|
||||||
|
|
||||||
|
case localChanged && remoteChanged:
|
||||||
|
switch {
|
||||||
|
case !s.CanWrite:
|
||||||
|
return actRestore
|
||||||
|
case s.Strategy == "client-wins":
|
||||||
|
return actForcePush
|
||||||
|
case s.Strategy == "manual":
|
||||||
|
if s.ConflictSeen {
|
||||||
|
return actNothing
|
||||||
|
}
|
||||||
|
return actManual
|
||||||
|
default:
|
||||||
|
return actServerWins
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return actNothing
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDecideTable(t *testing.T) {
|
||||||
|
// A database this client has synced at version 2, digest "a".
|
||||||
|
base := situation{LocalExists: true, LocalSHA: "a", SyncedSHA: "a", SyncedVersion: 2,
|
||||||
|
RemoteSHA: "a", RemoteVersion: 2, CanWrite: true, Strategy: "server-wins"}
|
||||||
|
|
||||||
|
with := func(f func(*situation)) situation {
|
||||||
|
s := base
|
||||||
|
f(&s)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
s situation
|
||||||
|
want action
|
||||||
|
}{
|
||||||
|
{"settled", base, actNothing},
|
||||||
|
{"local save", with(func(s *situation) { s.LocalSHA = "b" }), actPush},
|
||||||
|
{"remote update", with(func(s *situation) { s.RemoteSHA, s.RemoteVersion = "c", 3 }), actPull},
|
||||||
|
{"missing local file", with(func(s *situation) { s.LocalExists = false }), actPull},
|
||||||
|
{"missing local file, empty database", situation{RemoteVersion: 0}, actNothing},
|
||||||
|
{"adopt a local file into an empty database",
|
||||||
|
situation{LocalExists: true, LocalSHA: "a", CanWrite: true, Strategy: "server-wins"}, actPush},
|
||||||
|
{"both changed, server wins", with(func(s *situation) {
|
||||||
|
s.LocalSHA, s.RemoteSHA, s.RemoteVersion = "b", "c", 3
|
||||||
|
}), actServerWins},
|
||||||
|
{"both changed, client wins", with(func(s *situation) {
|
||||||
|
s.LocalSHA, s.RemoteSHA, s.RemoteVersion, s.Strategy = "b", "c", 3, "client-wins"
|
||||||
|
}), actForcePush},
|
||||||
|
{"both changed, manual", with(func(s *situation) {
|
||||||
|
s.LocalSHA, s.RemoteSHA, s.RemoteVersion, s.Strategy = "b", "c", 3, "manual"
|
||||||
|
}), actManual},
|
||||||
|
{"manual conflict already reported", with(func(s *situation) {
|
||||||
|
s.LocalSHA, s.RemoteSHA, s.RemoteVersion, s.Strategy = "b", "c", 3, "manual"
|
||||||
|
s.ConflictSeen = true
|
||||||
|
}), actNothing},
|
||||||
|
{"local save without write access", with(func(s *situation) {
|
||||||
|
s.LocalSHA, s.CanWrite = "b", false
|
||||||
|
}), actRestore},
|
||||||
|
{"both changed without write access", with(func(s *situation) {
|
||||||
|
s.LocalSHA, s.RemoteSHA, s.RemoteVersion, s.CanWrite = "b", "c", 3, false
|
||||||
|
}), actRestore},
|
||||||
|
{"version moved but content identical", with(func(s *situation) {
|
||||||
|
s.RemoteVersion = 3
|
||||||
|
}), actPull},
|
||||||
|
} {
|
||||||
|
if got := decide(tc.s); got != tc.want {
|
||||||
|
t.Errorf("%s: decide = %s, want %s", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stateFile holds the mirror's bookkeeping. It sits inside the mirror
|
||||||
|
// directory so moving the directory moves its history with it.
|
||||||
|
const stateFile = ".keeporg-state.json"
|
||||||
|
|
||||||
|
// fileState is what this client last agreed with the server about one
|
||||||
|
// database, and where it keeps it on disk.
|
||||||
|
type fileState struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
File string `json:"file"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
ConflictMarker string `json:"conflict_marker,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is a human-readable account of one mirrored database.
|
||||||
|
type Status struct {
|
||||||
|
DatabaseID string `json:"database_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
File string `json:"file"`
|
||||||
|
Perm string `json:"perm"`
|
||||||
|
Strategy string `json:"merge_strategy"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
LastSync time.Time `json:"last_sync"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror keeps a directory of real .kdbx files in step with a server. Any
|
||||||
|
// KeePass application can open the files directly; the mirror notices a save
|
||||||
|
// and pushes it, and applies what other clients push.
|
||||||
|
//
|
||||||
|
// Every reconciliation holds m.mu, so there is exactly one writer to the
|
||||||
|
// directory and to the state file.
|
||||||
|
type Mirror struct {
|
||||||
|
api *API
|
||||||
|
dir string
|
||||||
|
log *log.Logger
|
||||||
|
poll time.Duration
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
st map[string]*fileState
|
||||||
|
status map[string]*Status
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMirror prepares dir and loads any previous state.
|
||||||
|
func NewMirror(api *API, dir string, lg *log.Logger, poll time.Duration) (*Mirror, error) {
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if poll <= 0 {
|
||||||
|
poll = 2 * time.Second
|
||||||
|
}
|
||||||
|
m := &Mirror{api: api, dir: dir, log: lg, poll: poll,
|
||||||
|
st: map[string]*fileState{}, status: map[string]*Status{}}
|
||||||
|
b, err := os.ReadFile(filepath.Join(dir, stateFile))
|
||||||
|
switch {
|
||||||
|
case os.IsNotExist(err):
|
||||||
|
return m, nil
|
||||||
|
case err != nil:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &m.st); err != nil {
|
||||||
|
return nil, fmt.Errorf("mirror: parse %s: %w", stateFile, err)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dir is the mirror directory.
|
||||||
|
func (m *Mirror) Dir() string { return m.dir }
|
||||||
|
|
||||||
|
// Run reconciles until ctx is done, driven by server push events and by a
|
||||||
|
// local rescan for saves made by a KeePass application.
|
||||||
|
func (m *Mirror) Run(ctx context.Context) error {
|
||||||
|
trigger := make(chan struct{}, 1)
|
||||||
|
wake := func() {
|
||||||
|
select {
|
||||||
|
case trigger <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go m.listen(ctx, wake)
|
||||||
|
|
||||||
|
if err := m.SyncAll(ctx); err != nil {
|
||||||
|
m.log.Printf("sync: %v", err)
|
||||||
|
}
|
||||||
|
t := time.NewTicker(m.poll)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-t.C:
|
||||||
|
if err := m.SyncAll(ctx); err != nil {
|
||||||
|
m.log.Printf("sync: %v", err)
|
||||||
|
}
|
||||||
|
case <-trigger:
|
||||||
|
// Let a burst of events settle before reconciling.
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
if err := m.SyncAll(ctx); err != nil {
|
||||||
|
m.log.Printf("sync: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// listen keeps an event stream open, reconnecting with backoff. Losing the
|
||||||
|
// stream only delays a reconcile, it never loses data.
|
||||||
|
func (m *Mirror) listen(ctx context.Context, wake func()) {
|
||||||
|
backoff := time.Second
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
err := m.api.Events(ctx, func(ev Event) {
|
||||||
|
backoff = time.Second
|
||||||
|
m.log.Printf("event %s %s v%d by %s", ev.Type, ev.Name, ev.Version, ev.By)
|
||||||
|
wake()
|
||||||
|
})
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
m.log.Printf("event stream: %v (retrying in %s)", err, backoff)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
if backoff < 60*time.Second {
|
||||||
|
backoff *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncAll reconciles every assigned database once.
|
||||||
|
func (m *Mirror) SyncAll(ctx context.Context) error {
|
||||||
|
dbs, err := m.api.Databases(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var firstErr error
|
||||||
|
for _, db := range dbs {
|
||||||
|
seen[db.ID] = true
|
||||||
|
if err := m.reconcile(ctx, db); err != nil {
|
||||||
|
m.setStatus(db, "error", err.Error())
|
||||||
|
m.log.Printf("%s: %v", db.Name, err)
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A database that is no longer assigned stops being tracked. Its file is
|
||||||
|
// left alone: deleting a user's local copy of a password database is not
|
||||||
|
// this program's decision.
|
||||||
|
for id := range m.st {
|
||||||
|
if !seen[id] {
|
||||||
|
delete(m.st, id)
|
||||||
|
delete(m.status, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := m.saveState(); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status reports the current state of each mirrored database.
|
||||||
|
func (m *Mirror) Status() []Status {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
out := make([]Status, 0, len(m.status))
|
||||||
|
for _, s := range m.status {
|
||||||
|
out = append(out, *s)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcile brings one database and its local file into agreement.
|
||||||
|
func (m *Mirror) reconcile(ctx context.Context, db Database) error {
|
||||||
|
st := m.trackedState(db)
|
||||||
|
path := filepath.Join(m.dir, st.File)
|
||||||
|
|
||||||
|
local, exists, err := readFileIfExists(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
localSHA := ""
|
||||||
|
if exists {
|
||||||
|
localSHA = digest(local)
|
||||||
|
}
|
||||||
|
sit := situation{
|
||||||
|
LocalExists: exists, LocalSHA: localSHA,
|
||||||
|
SyncedSHA: st.SHA256, SyncedVersion: st.Version,
|
||||||
|
RemoteSHA: db.SHA256, RemoteVersion: db.Version,
|
||||||
|
CanWrite: db.CanWrite(), Strategy: db.MergeStrategy,
|
||||||
|
ConflictSeen: st.ConflictMarker == conflictMarker(db.Version, localSHA),
|
||||||
|
}
|
||||||
|
act := decide(sit)
|
||||||
|
if act != actManual && st.ConflictMarker != "" {
|
||||||
|
st.ConflictMarker = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
switch act {
|
||||||
|
case actNothing:
|
||||||
|
if m.status[db.ID] == nil || m.status[db.ID].State == "" {
|
||||||
|
m.setStatus(db, "ok", "")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case actPull:
|
||||||
|
return m.pull(ctx, db, st, path)
|
||||||
|
|
||||||
|
case actPush:
|
||||||
|
return m.push(ctx, db, st, path, local, false)
|
||||||
|
|
||||||
|
case actForcePush:
|
||||||
|
return m.push(ctx, db, st, path, local, true)
|
||||||
|
|
||||||
|
case actServerWins, actRestore:
|
||||||
|
copyPath, err := m.keepConflictCopy(path, local)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := m.pull(ctx, db, st, path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
why := "the server copy was newer, your save was kept as " + filepath.Base(copyPath)
|
||||||
|
if act == actRestore {
|
||||||
|
why = "you have read-only access, your save was kept as " + filepath.Base(copyPath)
|
||||||
|
}
|
||||||
|
m.log.Printf("%s: %s", db.Name, why)
|
||||||
|
m.setStatus(db, "conflict-resolved", why)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case actManual:
|
||||||
|
remote, version, err := m.api.Pull(ctx, db.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
side := strings.TrimSuffix(path, ".kdbx") + fmt.Sprintf(".server-v%d.kdbx", version)
|
||||||
|
if err := writeFileAtomic(side, remote); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st.ConflictMarker = conflictMarker(db.Version, localSHA)
|
||||||
|
why := fmt.Sprintf("both copies changed; the server copy is in %s, merge it yourself",
|
||||||
|
filepath.Base(side))
|
||||||
|
m.log.Printf("%s: %s", db.Name, why)
|
||||||
|
m.setStatus(db, "conflict", why)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mirror) pull(ctx context.Context, db Database, st *fileState, path string) error {
|
||||||
|
data, version, err := m.api.Pull(ctx, db.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if version == 0 || len(data) == 0 {
|
||||||
|
return nil // nothing stored yet
|
||||||
|
}
|
||||||
|
if err := writeFileAtomic(path, data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st.Version, st.SHA256 = version, digest(data)
|
||||||
|
m.log.Printf("%s: pulled version %d into %s", db.Name, version, st.File)
|
||||||
|
m.setStatus(db, "ok", "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mirror) push(ctx context.Context, db Database, st *fileState, path string, data []byte, force bool) error {
|
||||||
|
res, err := m.api.Push(ctx, db.ID, st.Version, data, force)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
st.Version, st.SHA256 = res.Version, res.SHA256
|
||||||
|
if !res.Unchanged {
|
||||||
|
m.log.Printf("%s: pushed version %d", db.Name, res.Version)
|
||||||
|
}
|
||||||
|
m.setStatus(db, "ok", "")
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case IsConflict(err):
|
||||||
|
// The server moved while we were deciding. Re-read the policy from
|
||||||
|
// the server's own answer and resolve with it.
|
||||||
|
var e *Error
|
||||||
|
errors.As(err, &e)
|
||||||
|
strategy := e.MergeStrategy
|
||||||
|
if strategy == "" {
|
||||||
|
strategy = db.MergeStrategy
|
||||||
|
}
|
||||||
|
if strategy == "client-wins" && !force {
|
||||||
|
return m.push(ctx, db, st, path, data, true)
|
||||||
|
}
|
||||||
|
copyPath, cerr := m.keepConflictCopy(path, data)
|
||||||
|
if cerr != nil {
|
||||||
|
return cerr
|
||||||
|
}
|
||||||
|
if perr := m.pull(ctx, db, st, path); perr != nil {
|
||||||
|
return perr
|
||||||
|
}
|
||||||
|
why := "the server copy was newer, your save was kept as " + filepath.Base(copyPath)
|
||||||
|
m.log.Printf("%s: %s", db.Name, why)
|
||||||
|
m.setStatus(db, "conflict-resolved", why)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
var e *Error
|
||||||
|
if errors.As(err, &e) && e.Status == http.StatusForbidden {
|
||||||
|
copyPath, cerr := m.keepConflictCopy(path, data)
|
||||||
|
if cerr != nil {
|
||||||
|
return cerr
|
||||||
|
}
|
||||||
|
if perr := m.pull(ctx, db, st, path); perr != nil {
|
||||||
|
return perr
|
||||||
|
}
|
||||||
|
why := "you have read-only access, your save was kept as " + filepath.Base(copyPath)
|
||||||
|
m.setStatus(db, "conflict-resolved", why)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackedState returns the state for a database, following a rename made on
|
||||||
|
// the server.
|
||||||
|
func (m *Mirror) trackedState(db Database) *fileState {
|
||||||
|
st := m.st[db.ID]
|
||||||
|
if st == nil {
|
||||||
|
st = &fileState{Name: db.Name, File: m.fileNameFor(db)}
|
||||||
|
m.st[db.ID] = st
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
if st.File == "" {
|
||||||
|
st.File = m.fileNameFor(db)
|
||||||
|
}
|
||||||
|
if st.Name != db.Name {
|
||||||
|
want := m.fileNameFor(db)
|
||||||
|
old := filepath.Join(m.dir, st.File)
|
||||||
|
if _, err := os.Stat(old); err == nil && want != st.File {
|
||||||
|
if err := os.Rename(old, filepath.Join(m.dir, want)); err != nil {
|
||||||
|
m.log.Printf("%s: rename %s to %s: %v", db.Name, st.File, want, err)
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
m.log.Printf("renamed %s to %s", st.File, want)
|
||||||
|
}
|
||||||
|
st.Name, st.File = db.Name, want
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileNameFor turns a database name into a filename, keeping it unique
|
||||||
|
// within the mirror directory.
|
||||||
|
func (m *Mirror) fileNameFor(db Database) string {
|
||||||
|
base := sanitize(db.Name)
|
||||||
|
if base == "" {
|
||||||
|
base = "database"
|
||||||
|
}
|
||||||
|
name := base + ".kdbx"
|
||||||
|
for id, st := range m.st {
|
||||||
|
if id != db.ID && st.File == name {
|
||||||
|
return fmt.Sprintf("%s-%s.kdbx", base, shortID(db.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepConflictCopy preserves bytes that are about to be overwritten.
|
||||||
|
func (m *Mirror) keepConflictCopy(path string, data []byte) (string, error) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
copyPath := fmt.Sprintf("%s.conflict-%s.kdbx",
|
||||||
|
strings.TrimSuffix(path, ".kdbx"), time.Now().UTC().Format("20060102-150405"))
|
||||||
|
return copyPath, writeFileAtomic(copyPath, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mirror) setStatus(db Database, state, msg string) {
|
||||||
|
st := m.st[db.ID]
|
||||||
|
s := &Status{DatabaseID: db.ID, Name: db.Name, Perm: db.Perm,
|
||||||
|
Strategy: db.MergeStrategy, State: state, Message: msg, LastSync: time.Now()}
|
||||||
|
if st != nil {
|
||||||
|
s.File, s.Version = st.File, st.Version
|
||||||
|
}
|
||||||
|
m.status[db.ID] = s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mirror) saveState() error {
|
||||||
|
b, err := json.MarshalIndent(m.st, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeFileAtomic(filepath.Join(m.dir, stateFile), append(b, '\n'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- small helpers ----
|
||||||
|
|
||||||
|
func digest(b []byte) string {
|
||||||
|
sum := sha256.Sum256(b)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func conflictMarker(remoteVersion int64, localSHA string) string {
|
||||||
|
if localSHA == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d/%s", remoteVersion, localSHA)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFileIfExists(path string) ([]byte, bool, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
switch {
|
||||||
|
case os.IsNotExist(err):
|
||||||
|
return nil, false, nil
|
||||||
|
case err != nil:
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
return b, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFileAtomic replaces path in one step, so a KeePass application never
|
||||||
|
// reads a half-written database.
|
||||||
|
func writeFileAtomic(path string, data []byte) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := path + ".keeporg-tmp"
|
||||||
|
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := f.Write(data); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.Sync(); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitize keeps a database name usable as a filename on every platform.
|
||||||
|
func sanitize(name string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.TrimSpace(name) {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
|
||||||
|
r == '-', r == '_', r == '.', r == ' ':
|
||||||
|
b.WriteRune(r)
|
||||||
|
default:
|
||||||
|
b.WriteRune('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Trim(strings.TrimSuffix(b.String(), ".kdbx"), " .-")
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortID(id string) string {
|
||||||
|
if len(id) > 6 {
|
||||||
|
return id[:6]
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||||
|
"cc.tiredsysadmin.keeporg/internal/server"
|
||||||
|
"cc.tiredsysadmin.keeporg/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stack struct {
|
||||||
|
t *testing.T
|
||||||
|
admin *API
|
||||||
|
mir *Mirror
|
||||||
|
dbID string
|
||||||
|
srv *server.Server
|
||||||
|
url string
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = "admin-password-1"
|
||||||
|
|
||||||
|
// newStack runs a real server in-process with one database owned by the
|
||||||
|
// "everyone" group, and a mirror logged in as the administrator.
|
||||||
|
func newStack(t *testing.T) *stack {
|
||||||
|
t.Helper()
|
||||||
|
srv, err := server.New(server.Config{DataDir: t.TempDir()}, log.New(io.Discard, "", 0))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hash, err := secret.Hash(password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := srv.Store().Update(func(st *store.State) error {
|
||||||
|
st.Users["admin"] = &store.User{ID: "admin", Username: "admin", PassHash: hash, ServerAdmin: true}
|
||||||
|
st.Groups["everyone"] = &store.Group{ID: "everyone", Name: "everyone"}
|
||||||
|
st.Memberships = append(st.Memberships,
|
||||||
|
store.Membership{UserID: "admin", GroupID: "everyone", Role: store.RoleAdmin})
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
t.Cleanup(ts.Close)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
api := NewAPI(ts.URL, "")
|
||||||
|
sess, err := api.Login(ctx, "admin", password, "", "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
api.Token = sess.Token
|
||||||
|
|
||||||
|
db, err := api.CreateDatabase(ctx, "Team Vault", "everyone", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mir, err := NewMirror(api, t.TempDir(), log.New(io.Discard, "", 0), 50*time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &stack{t: t, admin: api, mir: mir, dbID: db.ID, srv: srv, url: ts.URL}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stack) path(name string) string { return filepath.Join(s.mir.Dir(), name) }
|
||||||
|
|
||||||
|
func (s *stack) write(name string, data string) {
|
||||||
|
s.t.Helper()
|
||||||
|
if err := os.WriteFile(s.path(name), []byte(data), 0o600); err != nil {
|
||||||
|
s.t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stack) read(name string) string {
|
||||||
|
s.t.Helper()
|
||||||
|
b, err := os.ReadFile(s.path(name))
|
||||||
|
if err != nil {
|
||||||
|
s.t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stack) sync() {
|
||||||
|
s.t.Helper()
|
||||||
|
if err := s.mir.SyncAll(context.Background()); err != nil {
|
||||||
|
s.t.Fatalf("sync: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remoteVersion is what the server currently holds.
|
||||||
|
func (s *stack) remote() (int64, string) {
|
||||||
|
s.t.Helper()
|
||||||
|
data, v, err := s.admin.Pull(context.Background(), s.dbID)
|
||||||
|
if err != nil {
|
||||||
|
s.t.Fatal(err)
|
||||||
|
}
|
||||||
|
return v, string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// conflictFiles lists the conflict copies the mirror left behind.
|
||||||
|
func (s *stack) conflictFiles() []string {
|
||||||
|
s.t.Helper()
|
||||||
|
entries, err := os.ReadDir(s.mir.Dir())
|
||||||
|
if err != nil {
|
||||||
|
s.t.Fatal(err)
|
||||||
|
}
|
||||||
|
out := []string{}
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.Contains(e.Name(), ".conflict-") || strings.Contains(e.Name(), ".server-v") {
|
||||||
|
out = append(out, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMirrorAdoptsLocalFileThenFollowsRemote(t *testing.T) {
|
||||||
|
s := newStack(t)
|
||||||
|
// A KeePass application saves a database into the mirror directory.
|
||||||
|
s.write("Team Vault.kdbx", "local-v1")
|
||||||
|
s.sync()
|
||||||
|
if v, data := s.remote(); v != 1 || data != "local-v1" {
|
||||||
|
t.Fatalf("after adopt: version %d data %q", v, data)
|
||||||
|
}
|
||||||
|
// Another client pushes a change; the mirror applies it locally.
|
||||||
|
if _, err := s.admin.Push(context.Background(), s.dbID, 1, []byte("remote-v2"), false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.sync()
|
||||||
|
if got := s.read("Team Vault.kdbx"); got != "remote-v2" {
|
||||||
|
t.Fatalf("local file = %q, want remote-v2", got)
|
||||||
|
}
|
||||||
|
// A settled mirror does nothing and burns no versions.
|
||||||
|
s.sync()
|
||||||
|
if v, _ := s.remote(); v != 2 {
|
||||||
|
t.Fatalf("version after idle sync = %d", v)
|
||||||
|
}
|
||||||
|
if got := s.conflictFiles(); len(got) != 0 {
|
||||||
|
t.Fatalf("unexpected conflict files %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMirrorMaterializesDatabaseItHasNeverSeen(t *testing.T) {
|
||||||
|
s := newStack(t)
|
||||||
|
if _, err := s.admin.Push(context.Background(), s.dbID, 0, []byte("from-elsewhere"), false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.sync()
|
||||||
|
if got := s.read("Team Vault.kdbx"); got != "from-elsewhere" {
|
||||||
|
t.Fatalf("local file = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerWinsKeepsLosingSaveAsConflictCopy(t *testing.T) {
|
||||||
|
s := newStack(t)
|
||||||
|
s.write("Team Vault.kdbx", "base")
|
||||||
|
s.sync()
|
||||||
|
|
||||||
|
// Simultaneous edits: this client saves locally while another client
|
||||||
|
// pushes a different version.
|
||||||
|
if _, err := s.admin.Push(context.Background(), s.dbID, 1, []byte("their-edit"), false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.write("Team Vault.kdbx", "my-edit")
|
||||||
|
s.sync()
|
||||||
|
|
||||||
|
if got := s.read("Team Vault.kdbx"); got != "their-edit" {
|
||||||
|
t.Fatalf("local file = %q, want the server copy", got)
|
||||||
|
}
|
||||||
|
files := s.conflictFiles()
|
||||||
|
if len(files) != 1 {
|
||||||
|
t.Fatalf("conflict files = %v, want exactly one", files)
|
||||||
|
}
|
||||||
|
if got := s.read(files[0]); got != "my-edit" {
|
||||||
|
t.Fatalf("conflict copy = %q, want my-edit", got)
|
||||||
|
}
|
||||||
|
if st := s.mir.Status(); len(st) != 1 || st[0].State != "conflict-resolved" {
|
||||||
|
t.Fatalf("status = %+v", st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientWinsOverwritesTheServer(t *testing.T) {
|
||||||
|
s := newStack(t)
|
||||||
|
s.write("Team Vault.kdbx", "base")
|
||||||
|
s.sync()
|
||||||
|
if _, err := s.admin.SetStrategy(context.Background(), s.dbID, "client-wins"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.admin.Push(context.Background(), s.dbID, 1, []byte("their-edit"), false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.write("Team Vault.kdbx", "my-edit")
|
||||||
|
s.sync()
|
||||||
|
|
||||||
|
if v, data := s.remote(); data != "my-edit" || v != 3 {
|
||||||
|
t.Fatalf("server holds version %d %q, want my-edit", v, data)
|
||||||
|
}
|
||||||
|
if got := s.read("Team Vault.kdbx"); got != "my-edit" {
|
||||||
|
t.Fatalf("local file = %q", got)
|
||||||
|
}
|
||||||
|
if got := s.conflictFiles(); len(got) != 0 {
|
||||||
|
t.Fatalf("client-wins should not leave conflict copies, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualStrategyLeavesBothCopiesOnce(t *testing.T) {
|
||||||
|
s := newStack(t)
|
||||||
|
s.write("Team Vault.kdbx", "base")
|
||||||
|
s.sync()
|
||||||
|
if _, err := s.admin.SetStrategy(context.Background(), s.dbID, "manual"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.admin.Push(context.Background(), s.dbID, 1, []byte("their-edit"), false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.write("Team Vault.kdbx", "my-edit")
|
||||||
|
s.sync()
|
||||||
|
|
||||||
|
if got := s.read("Team Vault.kdbx"); got != "my-edit" {
|
||||||
|
t.Fatalf("manual must not touch the local file, got %q", got)
|
||||||
|
}
|
||||||
|
files := s.conflictFiles()
|
||||||
|
if len(files) != 1 || !strings.Contains(files[0], ".server-v2.kdbx") {
|
||||||
|
t.Fatalf("conflict files = %v", files)
|
||||||
|
}
|
||||||
|
if got := s.read(files[0]); got != "their-edit" {
|
||||||
|
t.Fatalf("server copy = %q", got)
|
||||||
|
}
|
||||||
|
st := s.mir.Status()
|
||||||
|
if len(st) != 1 || st[0].State != "conflict" {
|
||||||
|
t.Fatalf("status = %+v", st)
|
||||||
|
}
|
||||||
|
// Repeated passes must not pile up more copies or more versions.
|
||||||
|
s.sync()
|
||||||
|
s.sync()
|
||||||
|
if files := s.conflictFiles(); len(files) != 1 {
|
||||||
|
t.Fatalf("conflict files after idle passes = %v", files)
|
||||||
|
}
|
||||||
|
if v, _ := s.remote(); v != 2 {
|
||||||
|
t.Fatalf("server version = %d, want 2", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadOnlyAccessRestoresTheServerCopy(t *testing.T) {
|
||||||
|
s := newStack(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := s.admin.Push(ctx, s.dbID, 0, []byte("official"), false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A reader in a child group with a read grant.
|
||||||
|
g, err := s.admin.CreateGroup(ctx, "readers", "everyone")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.admin.SetGrant(ctx, g.ID, s.dbID, "read"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hash, _ := secret.Hash("reader-password")
|
||||||
|
if err := s.srv.Store().Update(func(st *store.State) error {
|
||||||
|
st.Users["r"] = &store.User{ID: "r", Username: "reader", PassHash: hash}
|
||||||
|
st.Memberships = append(st.Memberships,
|
||||||
|
store.Membership{UserID: "r", GroupID: g.ID, Role: store.RoleMember})
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rapi := NewAPI(s.url, "")
|
||||||
|
sess, err := rapi.Login(ctx, "reader", "reader-password", "", "reader")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rapi.Token = sess.Token
|
||||||
|
rmir, err := NewMirror(rapi, t.TempDir(), log.New(io.Discard, "", 0), 50*time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := rmir.SyncAll(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
local := filepath.Join(rmir.Dir(), "Team Vault.kdbx")
|
||||||
|
if b, err := os.ReadFile(local); err != nil || string(b) != "official" {
|
||||||
|
t.Fatalf("reader local file = %q %v", b, err)
|
||||||
|
}
|
||||||
|
// The reader edits anyway: the edit is preserved beside the restored copy.
|
||||||
|
if err := os.WriteFile(local, []byte("reader-edit"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := rmir.SyncAll(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if b, _ := os.ReadFile(local); string(b) != "official" {
|
||||||
|
t.Fatalf("reader file = %q, want the server copy restored", b)
|
||||||
|
}
|
||||||
|
if v, data := s.remote(); v != 1 || data != "official" {
|
||||||
|
t.Fatalf("server changed: version %d %q", v, data)
|
||||||
|
}
|
||||||
|
entries, _ := os.ReadDir(rmir.Dir())
|
||||||
|
var found bool
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.Contains(e.Name(), ".conflict-") {
|
||||||
|
found = true
|
||||||
|
b, _ := os.ReadFile(filepath.Join(rmir.Dir(), e.Name()))
|
||||||
|
if string(b) != "reader-edit" {
|
||||||
|
t.Fatalf("conflict copy = %q", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("the rejected edit was not kept")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMirrorFollowsServerSideRename(t *testing.T) {
|
||||||
|
s := newStack(t)
|
||||||
|
s.write("Team Vault.kdbx", "v1")
|
||||||
|
s.sync()
|
||||||
|
if _, err := s.admin.RenameDatabase(context.Background(), s.dbID, "Company Vault"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.sync()
|
||||||
|
if got := s.read("Company Vault.kdbx"); got != "v1" {
|
||||||
|
t.Fatalf("renamed file = %q", got)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(s.path("Team Vault.kdbx")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("the old filename is still there")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAppliesAPushedUpdateWithoutWaitingForThePoll(t *testing.T) {
|
||||||
|
s := newStack(t)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
// A long poll interval proves the update arrived over the event stream.
|
||||||
|
mir, err := NewMirror(s.admin, s.mir.Dir(), log.New(io.Discard, "", 0), time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
go func() { _ = mir.Run(ctx) }()
|
||||||
|
|
||||||
|
if _, err := s.admin.Push(ctx, s.dbID, 0, []byte("pushed-by-another-client"), false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
deadline := time.After(10 * time.Second)
|
||||||
|
for {
|
||||||
|
if b, err := os.ReadFile(s.path("Team Vault.kdbx")); err == nil &&
|
||||||
|
string(b) == "pushed-by-another-client" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("the pushed update never reached the mirror")
|
||||||
|
case <-time.After(25 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sseDecoder reads the subset of the Server-Sent Events format the server
|
||||||
|
// emits: "event:" and "data:" lines, one JSON payload per event.
|
||||||
|
type sseDecoder struct {
|
||||||
|
sc *bufio.Scanner
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSSEDecoder(r io.Reader) *sseDecoder {
|
||||||
|
sc := bufio.NewScanner(r)
|
||||||
|
sc.Buffer(make([]byte, 0, 8<<10), 1<<20)
|
||||||
|
return &sseDecoder{sc: sc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// next returns the next event, or nil for a comment or keepalive line.
|
||||||
|
func (d *sseDecoder) next() (*Event, error) {
|
||||||
|
var (
|
||||||
|
name string
|
||||||
|
data string
|
||||||
|
)
|
||||||
|
for d.sc.Scan() {
|
||||||
|
line := d.sc.Text()
|
||||||
|
switch {
|
||||||
|
case line == "":
|
||||||
|
if data == "" {
|
||||||
|
name = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var ev Event
|
||||||
|
if err := json.Unmarshal([]byte(data), &ev); err != nil {
|
||||||
|
return nil, nil // skip a payload we cannot read
|
||||||
|
}
|
||||||
|
if ev.Type == "" {
|
||||||
|
ev.Type = name
|
||||||
|
}
|
||||||
|
return &ev, nil
|
||||||
|
case strings.HasPrefix(line, ":"):
|
||||||
|
continue
|
||||||
|
case strings.HasPrefix(line, "event:"):
|
||||||
|
name = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
|
||||||
|
case strings.HasPrefix(line, "data:"):
|
||||||
|
data += strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := d.sc.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, io.EOF
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user