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