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())
|
||||
}
|
||||
Reference in New Issue
Block a user