Add KeepOrg sync server: accounts, nested groups, versioned KDBX sync
Why: collaborative KeePass use needs one arbiter of "who wrote last" that never sees a master password, plus access control that follows an org chart. What: stdlib-only Go server. Metadata is a single JSON document with one writer and atomic commit; KDBX payloads are versioned blobs on disk. Access comes from a group tree where a grant on a group reaches every member below it. Login is password+TOTP, invite links, or OIDC through a browser-approved device flow. Pushes carry If-Match and are arbitrated by a per-database merge strategy (server-wins by default). Updates are pushed to clients over SSE. Proved: go test ./internal/... covers RFC 6238 vectors, ACL inheritance and isolation, store rollback, blob pruning, every push verdict, and an end-to-end HTTP pass over login, conflict, force, read-only grants, invite redemption, TOTP enrolment, device login and the event stream.
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
// Package server implements the KeepOrg synchronization server: accounts,
|
||||
// nested groups, per-database grants, and the versioned KDBX sync endpoints.
|
||||
//
|
||||
// The server never opens a KDBX file. It moves opaque bytes and arbitrates
|
||||
// who wrote last, which keeps the master passwords entirely on the clients.
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/blob"
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
)
|
||||
|
||||
// Config is the server's whole configuration surface.
|
||||
type Config struct {
|
||||
Addr string `json:"addr"`
|
||||
DataDir string `json:"data_dir"`
|
||||
ExternalURL string `json:"external_url"`
|
||||
SessionTTL time.Duration `json:"session_ttl"`
|
||||
MaxDBSize int64 `json:"max_db_size"`
|
||||
TLSCert string `json:"tls_cert,omitempty"`
|
||||
TLSKey string `json:"tls_key,omitempty"`
|
||||
OIDC OIDCConfig `json:"oidc"`
|
||||
}
|
||||
|
||||
// Defaults fills in every value a deployment may leave unset.
|
||||
func (c *Config) Defaults() {
|
||||
if c.Addr == "" {
|
||||
c.Addr = ":8765"
|
||||
}
|
||||
if c.DataDir == "" {
|
||||
c.DataDir = "/var/lib/keeporg"
|
||||
}
|
||||
if c.ExternalURL == "" {
|
||||
c.ExternalURL = "http://localhost" + c.Addr
|
||||
}
|
||||
c.ExternalURL = strings.TrimRight(c.ExternalURL, "/")
|
||||
if c.SessionTTL == 0 {
|
||||
c.SessionTTL = 90 * 24 * time.Hour
|
||||
}
|
||||
if c.MaxDBSize == 0 {
|
||||
c.MaxDBSize = 32 << 20
|
||||
}
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
cfg Config
|
||||
st *store.Store
|
||||
blobs *blob.Store
|
||||
hub *Hub
|
||||
oidc *oidcProvider
|
||||
mux *http.ServeMux
|
||||
log *log.Logger
|
||||
}
|
||||
|
||||
// New opens the server's data directory and wires up its routes.
|
||||
func New(cfg Config, lg *log.Logger) (*Server, error) {
|
||||
cfg.Defaults()
|
||||
st, err := store.Open(filepath.Join(cfg.DataDir, "state.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blobs, err := blob.Open(filepath.Join(cfg.DataDir, "blobs"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Server{cfg: cfg, st: st, blobs: blobs, hub: NewHub(), mux: http.NewServeMux(), log: lg}
|
||||
if cfg.OIDC.Enabled() {
|
||||
s.oidc = newOIDCProvider(cfg.OIDC, cfg.ExternalURL+"/oidc/callback")
|
||||
}
|
||||
s.routes()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Store exposes the metadata store for bootstrap commands.
|
||||
func (s *Server) Store() *store.Store { return s.st }
|
||||
|
||||
// Config returns the effective configuration.
|
||||
func (s *Server) Config() Config { return s.cfg }
|
||||
|
||||
// Handler is the server's HTTP entry point.
|
||||
func (s *Server) Handler() http.Handler { return s.mux }
|
||||
|
||||
// StartHousekeeping runs periodic expiry until done is closed.
|
||||
func (s *Server) StartHousekeeping(done <-chan struct{}) {
|
||||
go func() {
|
||||
t := time.NewTicker(time.Hour)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-t.C:
|
||||
if err := s.st.GC(time.Now()); err != nil {
|
||||
s.log.Printf("housekeeping: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) routes() {
|
||||
m := s.mux
|
||||
m.HandleFunc("GET /healthz", s.handleHealth)
|
||||
|
||||
// Authentication.
|
||||
m.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
|
||||
m.HandleFunc("POST /api/v1/auth/logout", s.authed(store.PermNone, s.handleLogout))
|
||||
m.HandleFunc("GET /api/v1/auth/whoami", s.authed(store.PermNone, s.handleWhoami))
|
||||
m.HandleFunc("POST /api/v1/auth/totp/enroll", s.authed(store.PermNone, s.handleTOTPEnroll))
|
||||
m.HandleFunc("POST /api/v1/auth/totp/confirm", s.authed(store.PermNone, s.handleTOTPConfirm))
|
||||
m.HandleFunc("POST /api/v1/auth/device/start", s.handleDeviceStart)
|
||||
m.HandleFunc("POST /api/v1/auth/device/poll", s.handleDevicePoll)
|
||||
|
||||
// Browser-facing pages: device approval, invite registration, OIDC.
|
||||
m.HandleFunc("GET /device", s.handleDevicePage)
|
||||
m.HandleFunc("POST /device", s.handleDeviceApprove)
|
||||
m.HandleFunc("GET /invite/{token}", s.handleInvitePage)
|
||||
m.HandleFunc("POST /invite/{token}", s.handleInviteRedeem)
|
||||
m.HandleFunc("GET /oidc/login", s.handleOIDCLogin)
|
||||
m.HandleFunc("GET /oidc/callback", s.handleOIDCCallback)
|
||||
|
||||
// Synchronization.
|
||||
m.HandleFunc("GET /api/v1/databases", s.authed(store.PermNone, s.handleListDatabases))
|
||||
m.HandleFunc("GET /api/v1/databases/{id}/blob", s.authed(store.PermNone, s.handleGetBlob))
|
||||
m.HandleFunc("PUT /api/v1/databases/{id}/blob", s.authed(store.PermNone, s.handlePutBlob))
|
||||
m.HandleFunc("GET /api/v1/events", s.authed(store.PermNone, s.handleEvents))
|
||||
|
||||
// Administration.
|
||||
m.HandleFunc("POST /api/v1/databases", s.authed(store.PermNone, s.handleCreateDatabase))
|
||||
m.HandleFunc("PATCH /api/v1/databases/{id}", s.authed(store.PermNone, s.handlePatchDatabase))
|
||||
m.HandleFunc("DELETE /api/v1/databases/{id}", s.authed(store.PermNone, s.handleDeleteDatabase))
|
||||
m.HandleFunc("GET /api/v1/groups", s.authed(store.PermNone, s.handleListGroups))
|
||||
m.HandleFunc("POST /api/v1/groups", s.authed(store.PermNone, s.handleCreateGroup))
|
||||
m.HandleFunc("DELETE /api/v1/groups/{id}", s.authed(store.PermNone, s.handleDeleteGroup))
|
||||
m.HandleFunc("POST /api/v1/groups/{id}/members", s.authed(store.PermNone, s.handleSetMember))
|
||||
m.HandleFunc("DELETE /api/v1/groups/{id}/members/{user}", s.authed(store.PermNone, s.handleRemoveMember))
|
||||
m.HandleFunc("POST /api/v1/grants", s.authed(store.PermNone, s.handleSetGrant))
|
||||
m.HandleFunc("DELETE /api/v1/grants", s.authed(store.PermNone, s.handleDeleteGrant))
|
||||
m.HandleFunc("POST /api/v1/invites", s.authed(store.PermNone, s.handleCreateInvite))
|
||||
m.HandleFunc("GET /api/v1/invites", s.authed(store.PermNone, s.handleListInvites))
|
||||
m.HandleFunc("GET /api/v1/users", s.authed(store.PermNone, s.handleListUsers))
|
||||
}
|
||||
|
||||
// ---- request plumbing ----
|
||||
|
||||
type apiError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"error"`
|
||||
}
|
||||
|
||||
var errUnauthorized = errors.New("unauthorized")
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if v != nil {
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
}
|
||||
|
||||
func fail(w http.ResponseWriter, status int, code, msg string) {
|
||||
writeJSON(w, status, apiError{Code: code, Message: msg})
|
||||
}
|
||||
|
||||
func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
fail(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handler is an authenticated request handler.
|
||||
type handler func(http.ResponseWriter, *http.Request, *store.User)
|
||||
|
||||
// authed resolves the bearer token to a live, enabled user before calling fn.
|
||||
// Per-object permissions are checked inside each handler, where the object is
|
||||
// known; min is accepted for documentation of the minimum account level.
|
||||
func (s *Server) authed(_ store.Perm, fn handler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, err := s.userFromRequest(r)
|
||||
if err != nil {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="keeporg"`)
|
||||
fail(w, http.StatusUnauthorized, "unauthorized", "invalid or expired token")
|
||||
return
|
||||
}
|
||||
fn(w, r, u)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) userFromRequest(r *http.Request) (*store.User, error) {
|
||||
tok := bearer(r)
|
||||
if tok == "" {
|
||||
return nil, errUnauthorized
|
||||
}
|
||||
var out *store.User
|
||||
s.st.Read(func(st *store.State) {
|
||||
sess := st.Sessions[tok]
|
||||
if sess == nil || time.Now().After(sess.ExpiresAt) {
|
||||
return
|
||||
}
|
||||
if u := st.Users[sess.UserID]; u != nil && !u.Disabled {
|
||||
cp := *u
|
||||
out = &cp
|
||||
}
|
||||
})
|
||||
if out == nil {
|
||||
return nil, errUnauthorized
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// bearer extracts a token from the Authorization header, or from the
|
||||
// access_token query parameter, which EventSource clients need because they
|
||||
// cannot set headers.
|
||||
func bearer(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if after, ok := strings.CutPrefix(h, "Bearer "); ok {
|
||||
return strings.TrimSpace(after)
|
||||
}
|
||||
return r.URL.Query().Get("access_token")
|
||||
}
|
||||
|
||||
func (s *Server) newSession(st *store.State, userID, label string) *store.Session {
|
||||
sess := &store.Session{
|
||||
Token: secret.Token(),
|
||||
UserID: userID,
|
||||
Label: label,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(s.cfg.SessionTTL),
|
||||
}
|
||||
st.Sessions[sess.Token] = sess
|
||||
return sess
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
var users, dbs int
|
||||
s.st.Read(func(st *store.State) { users, dbs = len(st.Users), len(st.Databases) })
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok", "users": users, "databases": dbs, "streams": s.hub.Count(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user