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:
2026-10-29 16:01:35 +01:00
parent 8bbb3179a1
commit 4d0d53ff04
10 changed files with 2288 additions and 0 deletions
+80
View File
@@ -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)
}