Why: the set has to be installable and publishable, not just buildable, and the next person needs the reasoning, not only the code. What: - android/: Kotlin client with no third-party dependencies. A DocumentsProvider publishes the mirrored databases to every file picker, so KeePassDX opens them as ordinary documents; closing a file it wrote triggers a sync. A foreground service holds the event stream open. Mirror.decide transcribes the Go merge table, so both platforms resolve a conflict identically. - Makefile as the single entrypoint, with file targets so only what changed is rebuilt: host and cross builds, both .deb packages, the macOS DMG with its /Applications symlink, the Windows zip, the APK under its F-Droid publishing name, and manual-only android-publish. - versions.mk holds one version per platform plus the Android versionCode; `make bump` and `make changelog` move them. Debian changelog is tracked. - systemd units (hardened system unit for the server, user unit for the agent), macOS bundle and launch agent, Windows Task Scheduler job. - docs/HANDOVER.md on architecture and every decision that shaped it, docs/PROTOCOL.md for the API, README for the quick start. Proved: make check and make test-race both pass; built both .deb packages and inspected their contents, built the Windows zip, cross-compiled all five desktop targets, and exercised make bump. The Android module is written but not compiled: no JDK or Android SDK in this environment, which HANDOVER.md states plainly.
254 lines
7.8 KiB
Go
254 lines
7.8 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(s.handleLogout))
|
|
m.HandleFunc("GET /api/v1/auth/whoami", s.authed(s.handleWhoami))
|
|
m.HandleFunc("POST /api/v1/auth/totp/enroll", s.authed(s.handleTOTPEnroll))
|
|
m.HandleFunc("POST /api/v1/auth/totp/confirm", s.authed(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(s.handleListDatabases))
|
|
m.HandleFunc("GET /api/v1/databases/{id}/blob", s.authed(s.handleGetBlob))
|
|
m.HandleFunc("PUT /api/v1/databases/{id}/blob", s.authed(s.handlePutBlob))
|
|
m.HandleFunc("GET /api/v1/events", s.authed(s.handleEvents))
|
|
|
|
// Administration.
|
|
m.HandleFunc("POST /api/v1/databases", s.authed(s.handleCreateDatabase))
|
|
m.HandleFunc("PATCH /api/v1/databases/{id}", s.authed(s.handlePatchDatabase))
|
|
m.HandleFunc("DELETE /api/v1/databases/{id}", s.authed(s.handleDeleteDatabase))
|
|
m.HandleFunc("GET /api/v1/groups", s.authed(s.handleListGroups))
|
|
m.HandleFunc("POST /api/v1/groups", s.authed(s.handleCreateGroup))
|
|
m.HandleFunc("DELETE /api/v1/groups/{id}", s.authed(s.handleDeleteGroup))
|
|
m.HandleFunc("POST /api/v1/groups/{id}/members", s.authed(s.handleSetMember))
|
|
m.HandleFunc("DELETE /api/v1/groups/{id}/members/{user}", s.authed(s.handleRemoveMember))
|
|
m.HandleFunc("POST /api/v1/grants", s.authed(s.handleSetGrant))
|
|
m.HandleFunc("DELETE /api/v1/grants", s.authed(s.handleDeleteGrant))
|
|
m.HandleFunc("POST /api/v1/invites", s.authed(s.handleCreateInvite))
|
|
m.HandleFunc("GET /api/v1/invites", s.authed(s.handleListInvites))
|
|
m.HandleFunc("GET /api/v1/users", s.authed(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 permission is then checked inside the handler, where the object
|
|
// being acted on is known.
|
|
func (s *Server) authed(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(),
|
|
})
|
|
}
|