diff --git a/cmd/keeporg/commands.go b/cmd/keeporg/commands.go new file mode 100644 index 0000000..5b400bc --- /dev/null +++ b/cmd/keeporg/commands.go @@ -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 or -delete ") + } + 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 or -remove ") + } + 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 -group , or -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 +} diff --git a/cmd/keeporg/main.go b/cmd/keeporg/main.go new file mode 100644 index 0000000..c0b5dd1 --- /dev/null +++ b/cmd/keeporg/main.go @@ -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 [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 -h` for a command's flags.\n"+ + "Configuration lives in %s\n", client.DefaultConfigPath()) +} diff --git a/internal/client/admin.go b/internal/client/admin.go new file mode 100644 index 0000000..93ebac5 --- /dev/null +++ b/internal/client/admin.go @@ -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) +} diff --git a/internal/client/api.go b/internal/client/api.go new file mode 100644 index 0000000..468528a --- /dev/null +++ b/internal/client/api.go @@ -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 +} diff --git a/internal/client/config.go b/internal/client/config.go new file mode 100644 index 0000000..fe0cc76 --- /dev/null +++ b/internal/client/config.go @@ -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) +} diff --git a/internal/client/decide.go b/internal/client/decide.go new file mode 100644 index 0000000..23a1477 --- /dev/null +++ b/internal/client/decide.go @@ -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 +} diff --git a/internal/client/decide_test.go b/internal/client/decide_test.go new file mode 100644 index 0000000..51bcc29 --- /dev/null +++ b/internal/client/decide_test.go @@ -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) + } + } +} diff --git a/internal/client/mirror.go b/internal/client/mirror.go new file mode 100644 index 0000000..cba570d --- /dev/null +++ b/internal/client/mirror.go @@ -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 +} diff --git a/internal/client/mirror_test.go b/internal/client/mirror_test.go new file mode 100644 index 0000000..ae9397e --- /dev/null +++ b/internal/client/mirror_test.go @@ -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): + } + } +} diff --git a/internal/client/sse.go b/internal/client/sse.go new file mode 100644 index 0000000..fa97d8f --- /dev/null +++ b/internal/client/sse.go @@ -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 +}