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