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