Files
keeporg/internal/server/server.go
T
root 8bbb3179a1 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.
2026-10-29 15:54:10 +01:00

254 lines
8.2 KiB
Go

// 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(),
})
}