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:
2026-10-29 15:54:10 +01:00
commit 8bbb3179a1
26 changed files with 3897 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
/build/
/dist/
*.kdbx
*.deb
*.dmg
*.apk
*.aab
/android/.gradle/
/android/build/
/android/app/build/
/android/local.properties
+27
View File
@@ -0,0 +1,27 @@
# General rules
Be absolutely frugal about spending remaining resources. Default to short, direct answers. Drop the opening pleasantries and the wrap-up summary. Lead with the answer, add detail only if it's needed, and stop when you're done.
# Naming and versioning
Applications shall use `cc.tiredsysadmin.<operating_system>.<application>` namespace for their unique identifiers. Versioning is done automatically each change according to semantic versioning policy. Track application versions of each platform accordingly (Android, macOS, Linux, BSD, Windows).
# Tools and building
Use Makefile as an entrypoint for building and publishing applications. Each target is atomic, construct dependencies.
For macOS versions always produce a DMG with a symlink to `/Applications` folder.
For Linux versions always produce a boilerplate for building .deb packages, also track changelog.
# Documentation
Write developer handover documentation explaining architecture and decisions.
# Publishing
Android applications shall be published to a private F-Droid repository at rcmd@garage.rcmd.space:/var/lib/fdroid/unsigned, and the application metadata must be published at rcmd@garage.rcmd.space:/var/lib/fdroid/metadata. Publishing is always a manual action, never automatic.
Android artifact naming scheme for publishing to unsigned directory is always `<application_id>_<version_code>.apk`
MacOS, Linux, and Windows versions keep builds locally.
+152
View File
@@ -0,0 +1,152 @@
---
name: ub-mode
description: >
Follow the reduced principles when writing or changing code. Every change
follows First Make it Work, then Then Make it Right, then Follow Practice.
Use when the user runs /ub-mode or asks for the reduced principles.
---
# ub-mode
Every time you are asked to make a change follow:
* First Make it Work.
* Then Make it Right.
* Follow Practice
# First Make it Work
## Laziness Protocol
* One decision lives in one place.
* Ship the smallest edit that solves the problem.
## Foundational Thinking
* Get the data shape right before you write the logic.
* Do first whatever helps every later phase.
* If a concurrent writer would matter, do not share state.
## TDD
* Write the failing test before you change the code.
* See it fail for the reason you expect.
* Make the smallest change that makes it pass.
* If an honest test is impractical, do not write a bad one.
## Redesign From First Principles
* Do not bolt a new requirement onto the old design.
* Ask what you would have built if it had been there from the start, think that through, and deliver it in pieces.
## Attack the Premise
* When two fixes that share one premise fail the same gate, write that premise in one sentence and count which actors hold the imbalance.
* Remove the assignment.
* Do not start another fix until you have done that.
## Subtract Before You Add
* Take complexity out before you add anything.
## Outcome-Oriented Execution
* Aim at the end state you can check.
* A break in the middle is allowed when you planned it and can undo it.
## Build the Lever
* If the work is not obvious at a glance, build the tool that does it.
* Do one unit by hand, then make the tool match that unit.
## Architecture
### Model the Domain
* Put the domain in a structure that makes the illegal states impossible.
* Maintain a glossary of domain terms.
* If the code is already clear and local, leave it.
### Boundary Discipline
* Check the data where it enters.
* Inside, trust it.
* Keep the business logic in a pure function.
### Type System Discipline
* Make the illegal value impossible to write.
* Parse outside data once, where it comes in.
* Do not silence the compiler.
### Make Operations Idempotent
* Running it twice, or running it after a crash halfway through, ends in the same state.
### Separate Before Serializing Shared State
* If they do not need the same mutable thing, give each its own.
* If they do, allow one writer, and enforce that in the structure.
# Then Make it Right
## Fix Root Causes
* Reproduce it.
* Fix the cause.
* A guard that only hides the crash is not a fix.
## Sequence work into verifiable units
* Finish one small unit and check it.
* Then start the next.
## Test Behavior, Not Implementation
* The test calls the code the way its user does and checks the result that user would see.
* If the test would still pass when every function returned nothing, throw it out.
## Crapper
* Use the crapper tool
* Reduce each function under CRAP 10 by splitting it, by testing it, or both.
* Consider whether case statements are worth reducing.
## Dryer
* Use the dryer tool to eliminate duplication that rank 0.8 or above.
## Mutator
* Use the mutator tool
* Cover the uncovered.
* Kill the survivors.
* Delete dead code.
## Refactoring
* Pin what the code does.
* Change the structure.
* Prove the behavior did not change.
# Practice
## Keep a Log
* Keep a log of every action you take and the reason you took it.
* Report that log upon completion.
## Guard the Context Window
* Keep the main thread small.
* Send the bulk elsewhere, and keep the summary.
## Worktree Cleanup
* Find what is safe to remove, confirm nothing still in use needs it, then reclaim the disk.
* Uncommitted work waits for a decision.
## Commit and Push
* Commit often, in small ordered commits.
* Each commit says why, what changed, and how you proved it.
* Push to the main line at the end.
+209
View File
@@ -0,0 +1,209 @@
// Command keeporgd is the KeepOrg synchronization server.
//
// It stores KDBX files as opaque, versioned blobs and arbitrates concurrent
// writes. It never sees a master password and cannot read a database.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/server"
"cc.tiredsysadmin.keeporg/internal/store"
)
// Version is stamped at build time from versions.mk.
var Version = "dev"
// fileConfig is the on-disk shape of the configuration. It keeps durations as
// plain numbers so the file stays easy to edit by hand.
type fileConfig struct {
Addr string `json:"addr"`
DataDir string `json:"data_dir"`
ExternalURL string `json:"external_url"`
SessionTTLHours int `json:"session_ttl_hours"`
MaxDBSizeMB int64 `json:"max_db_size_mb"`
TLSCert string `json:"tls_cert"`
TLSKey string `json:"tls_key"`
OIDC server.OIDCConfig `json:"oidc"`
}
func (f fileConfig) toConfig() server.Config {
c := server.Config{
Addr: f.Addr, DataDir: f.DataDir, ExternalURL: f.ExternalURL,
TLSCert: f.TLSCert, TLSKey: f.TLSKey, OIDC: f.OIDC,
}
if f.SessionTTLHours > 0 {
c.SessionTTL = time.Duration(f.SessionTTLHours) * time.Hour
}
if f.MaxDBSizeMB > 0 {
c.MaxDBSize = f.MaxDBSizeMB << 20
}
return c
}
func main() {
log.SetFlags(log.LstdFlags | log.LUTC)
lg := log.New(os.Stderr, "keeporgd: ", log.LstdFlags|log.LUTC)
var (
configPath = flag.String("config", "", "path to a JSON configuration file")
addr = flag.String("addr", "", "listen address (overrides the config file)")
dataDir = flag.String("data", "", "data directory (overrides the config file)")
extURL = flag.String("url", "", "externally reachable base URL (overrides the config file)")
showVer = flag.Bool("version", false, "print the version and exit")
writeCfg = flag.String("write-config", "", "write a commented example config to this path and exit")
)
flag.Parse()
if *showVer {
fmt.Println("keeporgd", Version)
return
}
if *writeCfg != "" {
if err := writeExampleConfig(*writeCfg); err != nil {
lg.Fatal(err)
}
fmt.Println("wrote", *writeCfg)
return
}
var fc fileConfig
if *configPath != "" {
b, err := os.ReadFile(*configPath)
if err != nil {
lg.Fatalf("read config: %v", err)
}
if err := json.Unmarshal(b, &fc); err != nil {
lg.Fatalf("parse config: %v", err)
}
}
cfg := fc.toConfig()
if *addr != "" {
cfg.Addr = *addr
}
if *dataDir != "" {
cfg.DataDir = *dataDir
}
if *extURL != "" {
cfg.ExternalURL = *extURL
}
srv, err := server.New(cfg, lg)
if err != nil {
lg.Fatalf("start: %v", err)
}
if err := bootstrap(srv, lg); err != nil {
lg.Fatalf("bootstrap: %v", err)
}
done := make(chan struct{})
srv.StartHousekeeping(done)
hs := &http.Server{
Addr: srv.Config().Addr,
Handler: srv.Handler(),
ReadHeaderTimeout: 15 * time.Second,
// No write timeout: the event stream is long lived by design.
IdleTimeout: 120 * time.Second,
}
go func() {
eff := srv.Config()
lg.Printf("version %s listening on %s (external %s, data %s)",
Version, eff.Addr, eff.ExternalURL, eff.DataDir)
var err error
if eff.TLSCert != "" && eff.TLSKey != "" {
err = hs.ListenAndServeTLS(eff.TLSCert, eff.TLSKey)
} else {
err = hs.ListenAndServe()
}
if err != nil && !errors.Is(err, http.ErrServerClosed) {
lg.Fatalf("listen: %v", err)
}
}()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
close(done)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := hs.Shutdown(ctx); err != nil {
lg.Printf("shutdown: %v", err)
}
lg.Print("stopped")
}
// bootstrap creates the first administrator and a root group on an empty
// server, so a fresh deployment is immediately usable. It is idempotent.
func bootstrap(srv *server.Server, lg *log.Logger) error {
password := os.Getenv("KEEPORG_ADMIN_PASSWORD")
generated := false
if password == "" {
password = secret.Token()[:20]
generated = true
}
var created bool
err := srv.Store().Update(func(st *store.State) error {
if len(st.Users) > 0 {
return nil
}
hash, err := secret.Hash(password)
if err != nil {
return err
}
u := &store.User{ID: secret.ID(), Username: "admin", PassHash: hash,
ServerAdmin: true, CreatedAt: time.Now()}
st.Users[u.ID] = u
g := &store.Group{ID: secret.ID(), Name: "everyone", CreatedAt: time.Now()}
st.Groups[g.ID] = g
st.Memberships = append(st.Memberships,
store.Membership{UserID: u.ID, GroupID: g.ID, Role: store.RoleAdmin})
created = true
return nil
})
if err != nil || !created {
return err
}
if generated {
lg.Printf("created the first administrator: username %q password %q "+
"(shown once, change it or enrol TOTP now)", "admin", password)
} else {
lg.Print(`created the first administrator: username "admin" with the password from KEEPORG_ADMIN_PASSWORD`)
}
return nil
}
func writeExampleConfig(path string) error {
ex := fileConfig{
Addr: ":8765",
DataDir: "/var/lib/keeporg",
ExternalURL: "https://keeporg.example.com",
SessionTTLHours: 24 * 90,
MaxDBSizeMB: 32,
OIDC: server.OIDCConfig{
Issuer: "https://id.example.com/realms/main", ClientID: "keeporg",
ClientSecret: "change-me", Scopes: "openid profile email", AutoCreate: false,
},
}
b, err := json.MarshalIndent(ex, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, append(b, '\n'), 0o600)
}
+3
View File
@@ -0,0 +1,3 @@
module cc.tiredsysadmin.keeporg
go 1.24
+115
View File
@@ -0,0 +1,115 @@
// Package blob stores the KDBX payloads. Each database keeps a numbered
// version history, which makes a rejected push recoverable and a bad save
// reversible.
package blob
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
// KeepVersions is how many historical versions of each database are retained.
const KeepVersions = 20
type Store struct{ dir string }
// Open prepares dir as a blob store.
func Open(dir string) (*Store, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
return &Store{dir: dir}, nil
}
// SHA256 is the lowercase hex digest used everywhere as a content fingerprint.
func SHA256(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
// Put writes one version of a database. Writing the same version twice with
// the same bytes is a no-op, so a retried request is harmless.
func (s *Store) Put(dbID string, version int64, data []byte) error {
dir := filepath.Join(s.dir, dbID)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
path := s.versionPath(dbID, version)
if old, err := os.ReadFile(path); err == nil && SHA256(old) == SHA256(data) {
return nil
}
tmp := path + ".tmp"
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return err
}
if _, err := f.Write(data); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Sync(); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}
// Get reads one version of a database.
func (s *Store) Get(dbID string, version int64) ([]byte, error) {
return os.ReadFile(s.versionPath(dbID, version))
}
// Versions lists the stored versions of a database, oldest first.
func (s *Store) Versions(dbID string) []int64 {
entries, err := os.ReadDir(filepath.Join(s.dir, dbID))
if err != nil {
return nil
}
out := []int64{}
for _, e := range entries {
name := e.Name()
if !strings.HasSuffix(name, ".kdbx") {
continue
}
if v, err := strconv.ParseInt(strings.TrimSuffix(name, ".kdbx"), 10, 64); err == nil {
out = append(out, v)
}
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
// Prune removes all but the newest KeepVersions versions.
func (s *Store) Prune(dbID string) error {
vs := s.Versions(dbID)
if len(vs) <= KeepVersions {
return nil
}
for _, v := range vs[:len(vs)-KeepVersions] {
if err := os.Remove(s.versionPath(dbID, v)); err != nil {
return err
}
}
return nil
}
// Forget deletes every version of a database.
func (s *Store) Forget(dbID string) error {
return os.RemoveAll(filepath.Join(s.dir, dbID))
}
func (s *Store) versionPath(dbID string, version int64) string {
return filepath.Join(s.dir, dbID, fmt.Sprintf("%020d.kdbx", version))
}
+42
View File
@@ -0,0 +1,42 @@
package blob
import "testing"
func TestPutGetIsIdempotentAndVersioned(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
if err := s.Put("db", 1, []byte("v1")); err != nil {
t.Fatal(err)
}
if err := s.Put("db", 1, []byte("v1")); err != nil {
t.Fatal(err)
}
if err := s.Put("db", 2, []byte("v2")); err != nil {
t.Fatal(err)
}
got, err := s.Get("db", 1)
if err != nil || string(got) != "v1" {
t.Fatalf("get v1 = %q, %v", got, err)
}
if vs := s.Versions("db"); len(vs) != 2 || vs[0] != 1 || vs[1] != 2 {
t.Fatalf("versions = %v", vs)
}
}
func TestPruneKeepsNewest(t *testing.T) {
s, _ := Open(t.TempDir())
for i := int64(1); i <= KeepVersions+5; i++ {
if err := s.Put("db", i, []byte{byte(i)}); err != nil {
t.Fatal(err)
}
}
if err := s.Prune("db"); err != nil {
t.Fatal(err)
}
vs := s.Versions("db")
if len(vs) != KeepVersions || vs[0] != 6 {
t.Fatalf("after prune = %v", vs)
}
}
+96
View File
@@ -0,0 +1,96 @@
// Package secret holds the small amount of cryptography the server needs:
// random identifiers, bearer tokens and password verifiers.
package secret
import (
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
)
// iterations follows the OWASP recommendation for PBKDF2-HMAC-SHA256.
const iterations = 600000
// ID returns a short random identifier, safe in URLs and filenames.
func ID() string { return randHex(8) }
// Token returns a 256-bit bearer token.
func Token() string { return randHex(32) }
// UserCode returns a short, human-transcribable code for device login.
// The alphabet omits characters that are easy to confuse when read aloud.
func UserCode() string {
const alphabet = "BCDFGHJKLMNPQRSTVWXZ23456789"
b := randBytes(8)
out := make([]byte, 0, 9)
for i, v := range b {
if i == 4 {
out = append(out, '-')
}
out = append(out, alphabet[int(v)%len(alphabet)])
}
return string(out)
}
// Hash returns a verifier string for a password.
func Hash(password string) (string, error) {
if password == "" {
return "", errors.New("secret: empty password")
}
salt := randBytes(16)
dk, err := pbkdf2.Key(sha256.New, password, salt, iterations, 32)
if err != nil {
return "", err
}
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", iterations,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(dk)), nil
}
// Verify reports whether password matches a verifier produced by Hash.
// An unparsable or empty verifier never matches.
func Verify(verifier, password string) bool {
parts := strings.Split(verifier, "$")
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
return false
}
iter, err := strconv.Atoi(parts[1])
if err != nil || iter < 1000 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil {
return false
}
got, err := pbkdf2.Key(sha256.New, password, salt, iter, len(want))
if err != nil {
return false
}
return subtle.ConstantTimeCompare(got, want) == 1
}
// Equal compares two secrets without leaking their contents through timing.
func Equal(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func randBytes(n int) []byte {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic("secret: " + err.Error()) // crypto/rand never fails on supported platforms
}
return b
}
func randHex(n int) string { return hex.EncodeToString(randBytes(n)) }
+31
View File
@@ -0,0 +1,31 @@
package secret
import "testing"
func TestHashVerifyRoundTrip(t *testing.T) {
h, err := Hash("correct horse")
if err != nil {
t.Fatal(err)
}
if !Verify(h, "correct horse") {
t.Fatal("correct password rejected")
}
if Verify(h, "wrong horse") {
t.Fatal("wrong password accepted")
}
}
func TestVerifyRejectsJunkVerifier(t *testing.T) {
for _, v := range []string{"", "x", "pbkdf2-sha256$1$a$b", "md5$1$a$b"} {
if Verify(v, "anything") {
t.Fatalf("verifier %q accepted", v)
}
}
}
func TestUserCodeShape(t *testing.T) {
c := UserCode()
if len(c) != 9 || c[4] != '-' {
t.Fatalf("user code %q", c)
}
}
+524
View File
@@ -0,0 +1,524 @@
package server
import (
"errors"
"net/http"
"sort"
"strings"
"time"
"cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/store"
)
var errForbidden = errors.New("forbidden")
// deny maps a domain error onto a response.
func deny(w http.ResponseWriter, err error) {
switch {
case err == nil:
return
case errors.Is(err, errForbidden):
fail(w, http.StatusForbidden, "forbidden", "you do not administer that object")
default:
fail(w, http.StatusBadRequest, "bad_request", err.Error())
}
}
// ---- databases ----
func (s *Server) handleCreateDatabase(w http.ResponseWriter, r *http.Request, u *store.User) {
var req struct {
Name string `json:"name"`
OwnerGroupID string `json:"owner_group_id"`
MergeStrategy store.Strategy `json:"merge_strategy"`
}
if !readJSON(w, r, &req) {
return
}
var out dbView
err := s.st.Update(func(st *store.State) error {
if strings.TrimSpace(req.Name) == "" {
return errors.New("name is required")
}
if st.Groups[req.OwnerGroupID] == nil {
return errors.New("unknown owner group")
}
if !st.IsGroupAdmin(u.ID, req.OwnerGroupID) {
return errForbidden
}
strategy := req.MergeStrategy
if strategy == "" {
strategy = store.StrategyServerWins
}
if !strategy.Valid() {
return errors.New("unknown merge strategy")
}
db := &store.Database{ID: secret.ID(), Name: strings.TrimSpace(req.Name),
OwnerGroupID: req.OwnerGroupID, MergeStrategy: strategy, UpdatedAt: time.Now()}
st.Databases[db.ID] = db
out = toView(db, store.PermAdmin)
return nil
})
if err != nil {
deny(w, err)
return
}
s.notifyList(out.ID)
writeJSON(w, http.StatusCreated, out)
}
func (s *Server) handlePatchDatabase(w http.ResponseWriter, r *http.Request, u *store.User) {
id := r.PathValue("id")
var req struct {
Name *string `json:"name"`
MergeStrategy *store.Strategy `json:"merge_strategy"`
}
if !readJSON(w, r, &req) {
return
}
var out dbView
err := s.st.Update(func(st *store.State) error {
db := st.Databases[id]
if db == nil {
return errors.New("no such database")
}
if st.DatabasePerm(u.ID, id) != store.PermAdmin {
return errForbidden
}
if req.Name != nil {
if strings.TrimSpace(*req.Name) == "" {
return errors.New("name may not be empty")
}
db.Name = strings.TrimSpace(*req.Name)
}
if req.MergeStrategy != nil {
if !req.MergeStrategy.Valid() {
return errors.New("unknown merge strategy")
}
db.MergeStrategy = *req.MergeStrategy
}
out = toView(db, store.PermAdmin)
return nil
})
if err != nil {
deny(w, err)
return
}
s.notifyList(id)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleDeleteDatabase(w http.ResponseWriter, r *http.Request, u *store.User) {
id := r.PathValue("id")
var watchers []string
err := s.st.Update(func(st *store.State) error {
if st.Databases[id] == nil {
return errors.New("no such database")
}
if st.DatabasePerm(u.ID, id) != store.PermAdmin {
return errForbidden
}
watchers = st.Watchers(id)
delete(st.Databases, id)
st.Grants = filterGrants(st.Grants, func(g store.Grant) bool { return g.DatabaseID != id })
return nil
})
if err != nil {
deny(w, err)
return
}
if err := s.blobs.Forget(id); err != nil {
s.log.Printf("delete blobs for %s: %v", id, err)
}
s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: id})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// ---- groups and membership ----
type groupView struct {
ID string `json:"id"`
Name string `json:"name"`
ParentID string `json:"parent_id,omitempty"`
Admin bool `json:"admin"`
Members int `json:"members"`
}
func (s *Server) handleListGroups(w http.ResponseWriter, r *http.Request, u *store.User) {
out := []groupView{}
s.st.Read(func(st *store.State) {
counts := map[string]int{}
for _, m := range st.Memberships {
counts[m.GroupID]++
}
reach := st.EffectiveGroups(u.ID)
for id, g := range st.Groups {
admin := st.IsGroupAdmin(u.ID, id)
visible := admin || reach[id] || u.ServerAdmin
if !visible {
// A group below one the user administers is visible too.
for _, anc := range st.Ancestors(id) {
if st.IsGroupAdmin(u.ID, anc) {
visible = true
break
}
}
}
if visible {
out = append(out, groupView{ID: id, Name: g.Name, ParentID: g.ParentID,
Admin: admin, Members: counts[id]})
}
}
})
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
writeJSON(w, http.StatusOK, map[string]any{"groups": out})
}
func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request, u *store.User) {
var req struct {
Name string `json:"name"`
ParentID string `json:"parent_id"`
}
if !readJSON(w, r, &req) {
return
}
var out groupView
err := s.st.Update(func(st *store.State) error {
if strings.TrimSpace(req.Name) == "" {
return errors.New("name is required")
}
if req.ParentID == "" {
if !u.ServerAdmin {
return errForbidden
}
} else {
if st.Groups[req.ParentID] == nil {
return errors.New("unknown parent group")
}
if !st.IsGroupAdmin(u.ID, req.ParentID) {
return errForbidden
}
}
g := &store.Group{ID: secret.ID(), Name: strings.TrimSpace(req.Name),
ParentID: req.ParentID, CreatedAt: time.Now()}
st.Groups[g.ID] = g
out = groupView{ID: g.ID, Name: g.Name, ParentID: g.ParentID, Admin: true}
return nil
})
if err != nil {
deny(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
func (s *Server) handleDeleteGroup(w http.ResponseWriter, r *http.Request, u *store.User) {
id := r.PathValue("id")
err := s.st.Update(func(st *store.State) error {
g := st.Groups[id]
if g == nil {
return errors.New("no such group")
}
if !st.IsGroupAdmin(u.ID, id) {
return errForbidden
}
if len(st.Descendants(id)) > 1 {
return errors.New("the group still has child groups")
}
for _, db := range st.Databases {
if db.OwnerGroupID == id {
return errors.New("the group still owns databases")
}
}
for _, m := range st.Memberships {
if m.GroupID == id {
return errors.New("the group still has members")
}
}
delete(st.Groups, id)
st.Grants = filterGrants(st.Grants, func(gr store.Grant) bool { return gr.GroupID != id })
return nil
})
if err != nil {
deny(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func (s *Server) handleSetMember(w http.ResponseWriter, r *http.Request, u *store.User) {
groupID := r.PathValue("id")
var req struct {
Username string `json:"username"`
UserID string `json:"user_id"`
Role store.Role `json:"role"`
}
if !readJSON(w, r, &req) {
return
}
err := s.st.Update(func(st *store.State) error {
if st.Groups[groupID] == nil {
return errors.New("no such group")
}
if !st.IsGroupAdmin(u.ID, groupID) {
return errForbidden
}
target := st.Users[req.UserID]
if target == nil && req.Username != "" {
target = st.UserByName(req.Username)
}
if target == nil {
return errors.New("no such user")
}
role := req.Role
if role == "" {
role = store.RoleMember
}
if !role.Valid() {
return errors.New("role must be member or admin")
}
for i := range st.Memberships {
if st.Memberships[i].UserID == target.ID && st.Memberships[i].GroupID == groupID {
st.Memberships[i].Role = role
return nil
}
}
st.Memberships = append(st.Memberships, store.Membership{UserID: target.ID, GroupID: groupID, Role: role})
return nil
})
if err != nil {
deny(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request, u *store.User) {
groupID, userRef := r.PathValue("id"), r.PathValue("user")
err := s.st.Update(func(st *store.State) error {
if st.Groups[groupID] == nil {
return errors.New("no such group")
}
if !st.IsGroupAdmin(u.ID, groupID) {
return errForbidden
}
target := st.Users[userRef]
if target == nil {
target = st.UserByName(userRef)
}
if target == nil {
return errors.New("no such user")
}
out := st.Memberships[:0]
for _, m := range st.Memberships {
if m.UserID == target.ID && m.GroupID == groupID {
continue
}
out = append(out, m)
}
st.Memberships = out
return nil
})
if err != nil {
deny(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "removed"})
}
// ---- grants ----
func (s *Server) handleSetGrant(w http.ResponseWriter, r *http.Request, u *store.User) {
var req struct {
GroupID string `json:"group_id"`
DatabaseID string `json:"database_id"`
Perm store.Perm `json:"perm"`
}
if !readJSON(w, r, &req) {
return
}
var watchers []string
err := s.st.Update(func(st *store.State) error {
if st.Databases[req.DatabaseID] == nil {
return errors.New("no such database")
}
if st.Groups[req.GroupID] == nil {
return errors.New("no such group")
}
if st.DatabasePerm(u.ID, req.DatabaseID) != store.PermAdmin {
return errForbidden
}
if !req.Perm.Valid() {
return errors.New("perm must be read, write or admin")
}
for i := range st.Grants {
if st.Grants[i].GroupID == req.GroupID && st.Grants[i].DatabaseID == req.DatabaseID {
st.Grants[i].Perm = req.Perm
watchers = st.Watchers(req.DatabaseID)
return nil
}
}
st.Grants = append(st.Grants, store.Grant{GroupID: req.GroupID,
DatabaseID: req.DatabaseID, Perm: req.Perm})
watchers = st.Watchers(req.DatabaseID)
return nil
})
if err != nil {
deny(w, err)
return
}
s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: req.DatabaseID})
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleDeleteGrant(w http.ResponseWriter, r *http.Request, u *store.User) {
groupID := r.URL.Query().Get("group_id")
dbID := r.URL.Query().Get("database_id")
var watchers []string
err := s.st.Update(func(st *store.State) error {
if st.Databases[dbID] == nil {
return errors.New("no such database")
}
if st.DatabasePerm(u.ID, dbID) != store.PermAdmin {
return errForbidden
}
watchers = st.Watchers(dbID)
st.Grants = filterGrants(st.Grants, func(g store.Grant) bool {
return g.GroupID != groupID || g.DatabaseID != dbID
})
return nil
})
if err != nil {
deny(w, err)
return
}
s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: dbID})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// ---- invites ----
func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request, u *store.User) {
var req struct {
GroupID string `json:"group_id"`
Role store.Role `json:"role"`
TTLHours int `json:"ttl_hours"`
MaxUses int `json:"max_uses"`
}
if !readJSON(w, r, &req) {
return
}
inv := &store.Invite{Token: secret.Token(), GroupID: req.GroupID, Role: req.Role,
CreatedBy: u.ID, CreatedAt: time.Now(), MaxUses: req.MaxUses}
if inv.Role == "" {
inv.Role = store.RoleMember
}
if req.TTLHours <= 0 {
req.TTLHours = 168
}
if inv.MaxUses == 0 {
inv.MaxUses = 1
}
inv.ExpiresAt = time.Now().Add(time.Duration(req.TTLHours) * time.Hour)
err := s.st.Update(func(st *store.State) error {
if inv.GroupID == "" {
if !u.ServerAdmin {
return errors.New("group_id is required")
}
} else {
if st.Groups[inv.GroupID] == nil {
return errors.New("unknown group")
}
if !st.IsGroupAdmin(u.ID, inv.GroupID) {
return errForbidden
}
}
if !inv.Role.Valid() {
return errors.New("role must be member or admin")
}
st.Invites[inv.Token] = inv
return nil
})
if err != nil {
deny(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"token": inv.Token,
"url": s.cfg.ExternalURL + "/invite/" + inv.Token,
"expires_at": inv.ExpiresAt,
"max_uses": inv.MaxUses,
"group_id": inv.GroupID,
"role": inv.Role,
})
}
func (s *Server) handleListInvites(w http.ResponseWriter, r *http.Request, u *store.User) {
type inviteView struct {
Token string `json:"token"`
URL string `json:"url"`
GroupID string `json:"group_id,omitempty"`
Role store.Role `json:"role"`
Uses int `json:"uses"`
MaxUses int `json:"max_uses"`
ExpiresAt time.Time `json:"expires_at"`
}
out := []inviteView{}
s.st.Read(func(st *store.State) {
for _, inv := range st.Invites {
if !u.ServerAdmin && inv.CreatedBy != u.ID &&
!(inv.GroupID != "" && st.IsGroupAdmin(u.ID, inv.GroupID)) {
continue
}
out = append(out, inviteView{Token: inv.Token, URL: s.cfg.ExternalURL + "/invite/" + inv.Token,
GroupID: inv.GroupID, Role: inv.Role, Uses: inv.Uses, MaxUses: inv.MaxUses,
ExpiresAt: inv.ExpiresAt})
}
})
sort.Slice(out, func(i, j int) bool { return out[i].ExpiresAt.Before(out[j].ExpiresAt) })
writeJSON(w, http.StatusOK, map[string]any{"invites": out})
}
// ---- users ----
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request, u *store.User) {
out := []userView{}
s.st.Read(func(st *store.State) {
for id, target := range st.Users {
if u.ServerAdmin || id == u.ID || s.sharesAdministeredGroup(st, u.ID, id) {
out = append(out, view(target))
}
}
})
sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
writeJSON(w, http.StatusOK, map[string]any{"users": out})
}
// sharesAdministeredGroup reports whether admin administers any group the
// target belongs to, which is what makes the target visible to them.
func (s *Server) sharesAdministeredGroup(st *store.State, adminID, targetID string) bool {
for _, m := range st.Memberships {
if m.UserID == targetID && st.IsGroupAdmin(adminID, m.GroupID) {
return true
}
}
return false
}
// notifyList tells everyone with access that the database list changed.
func (s *Server) notifyList(dbID string) {
var watchers []string
s.st.Read(func(st *store.State) { watchers = st.Watchers(dbID) })
s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: dbID})
}
func filterGrants(in []store.Grant, keep func(store.Grant) bool) []store.Grant {
out := in[:0]
for _, g := range in {
if keep(g) {
out = append(out, g)
}
}
return out
}
+384
View File
@@ -0,0 +1,384 @@
package server
import (
"errors"
"net/http"
"strings"
"time"
"cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/store"
"cc.tiredsysadmin.keeporg/internal/totp"
)
// MinPasswordLen is the shortest password the server will store.
const MinPasswordLen = 10
// deviceTTL bounds how long a pending device login stays open.
const deviceTTL = 10 * time.Minute
var (
errBadCredentials = errors.New("bad credentials")
errTOTPRequired = errors.New("totp required")
errTOTPInvalid = errors.New("totp invalid")
)
// checkPassword validates a username, password and second factor against the
// state. It is the one place password login is decided.
func checkPassword(st *store.State, username, password, code string) (*store.User, error) {
u := st.UserByName(username)
if u == nil || u.Disabled || u.PassHash == "" {
return nil, errBadCredentials
}
if !secret.Verify(u.PassHash, password) {
return nil, errBadCredentials
}
if u.TOTPConfirmed {
if strings.TrimSpace(code) == "" {
return nil, errTOTPRequired
}
if !totp.Check(u.TOTPSecret, code, time.Now()) {
return nil, errTOTPInvalid
}
}
return u, nil
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TOTP string `json:"totp"`
Label string `json:"label"`
}
type loginResponse struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
User userView `json:"user"`
}
type userView struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
ServerAdmin bool `json:"server_admin"`
TOTPEnabled bool `json:"totp_enabled"`
}
func view(u *store.User) userView {
return userView{ID: u.ID, Username: u.Username, Email: u.Email,
ServerAdmin: u.ServerAdmin, TOTPEnabled: u.TOTPConfirmed}
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if !readJSON(w, r, &req) {
return
}
var resp loginResponse
err := s.st.Update(func(st *store.State) error {
u, err := checkPassword(st, req.Username, req.Password, req.TOTP)
if err != nil {
return err
}
sess := s.newSession(st, u.ID, req.Label)
resp = loginResponse{Token: sess.Token, ExpiresAt: sess.ExpiresAt, User: view(u)}
return nil
})
switch {
case errors.Is(err, errTOTPRequired):
fail(w, http.StatusUnauthorized, "totp_required", "second factor required")
case errors.Is(err, errTOTPInvalid):
fail(w, http.StatusUnauthorized, "totp_invalid", "second factor rejected")
case errors.Is(err, errBadCredentials):
fail(w, http.StatusUnauthorized, "bad_credentials", "unknown user or wrong password")
case err != nil:
fail(w, http.StatusInternalServerError, "internal", err.Error())
default:
writeJSON(w, http.StatusOK, resp)
}
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, _ *store.User) {
tok := bearer(r)
if err := s.st.Update(func(st *store.State) error {
delete(st.Sessions, tok)
return nil
}); err != nil {
fail(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"})
}
func (s *Server) handleWhoami(w http.ResponseWriter, r *http.Request, u *store.User) {
type groupView struct {
ID string `json:"id"`
Name string `json:"name"`
Role store.Role `json:"role"`
}
out := struct {
userView
Groups []groupView `json:"groups"`
}{userView: view(u), Groups: []groupView{}}
s.st.Read(func(st *store.State) {
for _, m := range st.Memberships {
if m.UserID != u.ID {
continue
}
if g := st.Groups[m.GroupID]; g != nil {
out.Groups = append(out.Groups, groupView{ID: g.ID, Name: g.Name, Role: m.Role})
}
}
})
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request, u *store.User) {
sec := totp.NewSecret()
if err := s.st.Update(func(st *store.State) error {
target := st.Users[u.ID]
if target == nil {
return errBadCredentials
}
target.TOTPSecret = sec
target.TOTPConfirmed = false
return nil
}); err != nil {
fail(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{
"secret": sec,
"uri": totp.URI("KeepOrg", u.Username, sec),
})
}
func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request, u *store.User) {
var req struct {
Code string `json:"code"`
}
if !readJSON(w, r, &req) {
return
}
err := s.st.Update(func(st *store.State) error {
target := st.Users[u.ID]
if target == nil || target.TOTPSecret == "" {
return errBadCredentials
}
if !totp.Check(target.TOTPSecret, req.Code, time.Now()) {
return errTOTPInvalid
}
target.TOTPConfirmed = true
return nil
})
switch {
case errors.Is(err, errTOTPInvalid):
fail(w, http.StatusBadRequest, "totp_invalid", "code rejected")
case err != nil:
fail(w, http.StatusInternalServerError, "internal", err.Error())
default:
writeJSON(w, http.StatusOK, map[string]bool{"totp_enabled": true})
}
}
// ---- device login: the browser approves, the client polls ----
func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) {
var req struct {
Label string `json:"label"`
}
if r.ContentLength > 0 && !readJSON(w, r, &req) {
return
}
d := &store.DeviceAuth{
DeviceCode: secret.Token(),
UserCode: secret.UserCode(),
Label: req.Label,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(deviceTTL),
}
if err := s.st.Update(func(st *store.State) error {
st.Devices[d.DeviceCode] = d
return nil
}); err != nil {
fail(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"device_code": d.DeviceCode,
"user_code": d.UserCode,
"verification_url": s.cfg.ExternalURL + "/device?code=" + d.UserCode,
"interval": 2,
"expires_in": int(deviceTTL.Seconds()),
})
}
func (s *Server) handleDevicePoll(w http.ResponseWriter, r *http.Request) {
var req struct {
DeviceCode string `json:"device_code"`
}
if !readJSON(w, r, &req) {
return
}
var (
token string
userID string
gone bool
)
err := s.st.Update(func(st *store.State) error {
d := st.Devices[req.DeviceCode]
if d == nil || time.Now().After(d.ExpiresAt) {
delete(st.Devices, req.DeviceCode)
gone = true
return nil
}
if d.SessionToken == "" {
return nil
}
token = d.SessionToken
if sess := st.Sessions[token]; sess != nil {
userID = sess.UserID
}
delete(st.Devices, req.DeviceCode)
return nil
})
switch {
case err != nil:
fail(w, http.StatusInternalServerError, "internal", err.Error())
case gone:
fail(w, http.StatusGone, "expired", "device code expired")
case token == "":
writeJSON(w, http.StatusAccepted, map[string]string{"status": "pending"})
default:
var resp loginResponse
s.st.Read(func(st *store.State) {
resp.Token = token
if sess := st.Sessions[token]; sess != nil {
resp.ExpiresAt = sess.ExpiresAt
}
if u := st.Users[userID]; u != nil {
resp.User = view(u)
}
})
writeJSON(w, http.StatusOK, resp)
}
}
func (s *Server) handleDevicePage(w http.ResponseWriter, r *http.Request) {
s.renderDevice(w, r.URL.Query().Get("code"), "")
}
func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
fail(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
code := strings.ToUpper(strings.TrimSpace(r.FormValue("code")))
err := s.st.Update(func(st *store.State) error {
d := st.DeviceByUserCode(code)
if d == nil || time.Now().After(d.ExpiresAt) {
return errors.New("unknown or expired code")
}
u, err := checkPassword(st, r.FormValue("username"), r.FormValue("password"), r.FormValue("totp"))
if err != nil {
return err
}
d.SessionToken = s.newSession(st, u.ID, d.Label).Token
return nil
})
switch {
case errors.Is(err, errTOTPRequired):
s.renderDevice(w, code, "This account requires a one-time code.")
case errors.Is(err, errTOTPInvalid):
s.renderDevice(w, code, "That one-time code was rejected.")
case errors.Is(err, errBadCredentials):
s.renderDevice(w, code, "Unknown user or wrong password.")
case err != nil:
s.renderDevice(w, code, err.Error())
default:
s.renderDone(w, "Device approved", "You can close this page and return to the application.")
}
}
// ---- invites ----
func (s *Server) handleInvitePage(w http.ResponseWriter, r *http.Request) {
tok := r.PathValue("token")
var ok bool
s.st.Read(func(st *store.State) {
if inv := st.Invites[tok]; inv != nil && inv.Usable(time.Now()) {
ok = true
}
})
if !ok {
s.renderDone(w, "Invite not valid", "This invite link has expired or has already been used.")
return
}
s.renderInvite(w, tok, "")
}
func (s *Server) handleInviteRedeem(w http.ResponseWriter, r *http.Request) {
tok := r.PathValue("token")
if err := r.ParseForm(); err != nil {
fail(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
err := s.st.Update(func(st *store.State) error {
inv := st.Invites[tok]
if inv == nil || !inv.Usable(time.Now()) {
return errors.New("this invite link has expired or has already been used")
}
if err := validUsername(username); err != nil {
return err
}
if len(password) < MinPasswordLen {
return errors.New("password must be at least 10 characters")
}
if password != r.FormValue("password2") {
return errors.New("the two passwords do not match")
}
if st.UserByName(username) != nil {
return errors.New("that username is taken")
}
hash, err := secret.Hash(password)
if err != nil {
return err
}
u := &store.User{ID: secret.ID(), Username: username, Email: strings.TrimSpace(r.FormValue("email")),
PassHash: hash, CreatedAt: time.Now()}
st.Users[u.ID] = u
if inv.GroupID != "" {
role := inv.Role
if !role.Valid() {
role = store.RoleMember
}
st.Memberships = append(st.Memberships, store.Membership{UserID: u.ID, GroupID: inv.GroupID, Role: role})
}
inv.Uses++
return nil
})
if err != nil {
s.renderInvite(w, tok, err.Error())
return
}
s.renderDone(w, "Account created",
"Sign in from the KeepOrg client with your new username and password.")
}
func validUsername(name string) error {
if len(name) < 2 || len(name) > 64 {
return errors.New("username must be between 2 and 64 characters")
}
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
r == '.', r == '-', r == '_', r == '@':
default:
return errors.New("username may contain letters, digits and . - _ @ only")
}
}
return nil
}
+94
View File
@@ -0,0 +1,94 @@
package server
import (
"encoding/json"
"sync"
)
// Event is one push notification sent to clients over SSE.
type Event struct {
Type string `json:"type"`
DatabaseID string `json:"database_id,omitempty"`
Name string `json:"name,omitempty"`
Version int64 `json:"version,omitempty"`
SHA256 string `json:"sha256,omitempty"`
By string `json:"by,omitempty"`
}
const (
EventUpdated = "db-updated"
EventChanged = "db-list-changed"
)
// subBuffer is how many events a slow subscriber may fall behind before its
// stream is dropped. A dropped client reconnects and resyncs from scratch,
// so losing events is safe; blocking the publisher would not be.
const subBuffer = 16
type subscriber struct {
userID string
ch chan Event
}
// Hub fans events out to the connected clients of specific users.
type Hub struct {
mu sync.Mutex
next int
subs map[int]*subscriber
}
func NewHub() *Hub { return &Hub{subs: map[int]*subscriber{}} }
// Subscribe registers a stream for one user and returns its id and channel.
func (h *Hub) Subscribe(userID string) (int, <-chan Event) {
h.mu.Lock()
defer h.mu.Unlock()
h.next++
s := &subscriber{userID: userID, ch: make(chan Event, subBuffer)}
h.subs[h.next] = s
return h.next, s.ch
}
// Unsubscribe removes a stream and closes its channel.
func (h *Hub) Unsubscribe(id int) {
h.mu.Lock()
defer h.mu.Unlock()
if s, ok := h.subs[id]; ok {
delete(h.subs, id)
close(s.ch)
}
}
// Publish delivers ev to every stream of every listed user. A stream that is
// not keeping up is dropped rather than waited for.
func (h *Hub) Publish(userIDs []string, ev Event) {
want := make(map[string]bool, len(userIDs))
for _, id := range userIDs {
want[id] = true
}
h.mu.Lock()
defer h.mu.Unlock()
for id, s := range h.subs {
if !want[s.userID] {
continue
}
select {
case s.ch <- ev:
default:
delete(h.subs, id)
close(s.ch)
}
}
}
// Count reports the number of live streams, for the health endpoint.
func (h *Hub) Count() int {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.subs)
}
func (e Event) encode() []byte {
b, _ := json.Marshal(e)
return b
}
+425
View File
@@ -0,0 +1,425 @@
package server
import (
"bufio"
"bytes"
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/store"
"cc.tiredsysadmin.keeporg/internal/totp"
)
type harness struct {
t *testing.T
srv *Server
http *httptest.Server
}
const adminPassword = "admin-password-1"
func newHarness(t *testing.T) *harness {
t.Helper()
dir := t.TempDir()
srv, err := New(Config{DataDir: dir, ExternalURL: "https://keeporg.test"},
log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
hash, err := secret.Hash(adminPassword)
if err != nil {
t.Fatal(err)
}
if err := srv.Store().Update(func(st *store.State) error {
st.Users["admin"] = &store.User{ID: "admin", Username: "admin", PassHash: hash, ServerAdmin: true}
st.Groups["everyone"] = &store.Group{ID: "everyone", Name: "everyone"}
st.Memberships = append(st.Memberships,
store.Membership{UserID: "admin", GroupID: "everyone", Role: store.RoleAdmin})
return nil
}); err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
_ = filepath.Join(dir, "state.json")
return &harness{t: t, srv: srv, http: ts}
}
// call sends a JSON request and decodes the JSON response.
func (h *harness) call(method, path, token string, body any) (int, map[string]any) {
h.t.Helper()
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
h.t.Fatal(err)
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequest(method, h.http.URL+path, rdr)
if err != nil {
h.t.Fatal(err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := h.http.Client().Do(req)
if err != nil {
h.t.Fatal(err)
}
defer resp.Body.Close()
out := map[string]any{}
raw, _ := io.ReadAll(resp.Body)
if len(raw) > 0 {
_ = json.Unmarshal(raw, &out)
}
return resp.StatusCode, out
}
func (h *harness) login(user, password, code string) (string, int, map[string]any) {
h.t.Helper()
status, body := h.call("POST", "/api/v1/auth/login", "",
map[string]string{"username": user, "password": password, "totp": code, "label": "test"})
tok, _ := body["token"].(string)
return tok, status, body
}
// push uploads bytes with an explicit If-Match and returns the response.
func (h *harness) push(token, dbID, ifMatch string, data []byte) (int, map[string]any, http.Header) {
h.t.Helper()
req, err := http.NewRequest("PUT", h.http.URL+"/api/v1/databases/"+dbID+"/blob", bytes.NewReader(data))
if err != nil {
h.t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("If-Match", ifMatch)
resp, err := h.http.Client().Do(req)
if err != nil {
h.t.Fatal(err)
}
defer resp.Body.Close()
out := map[string]any{}
raw, _ := io.ReadAll(resp.Body)
_ = json.Unmarshal(raw, &out)
return resp.StatusCode, out, resp.Header
}
// makeDB creates a database owned by the "everyone" group.
func (h *harness) makeDB(token, name string) string {
h.t.Helper()
status, body := h.call("POST", "/api/v1/databases", token,
map[string]string{"name": name, "owner_group_id": "everyone"})
if status != http.StatusCreated {
h.t.Fatalf("create database: %d %v", status, body)
}
return body["id"].(string)
}
func TestLoginRejectsWrongPassword(t *testing.T) {
h := newHarness(t)
if _, status, _ := h.login("admin", "nope", ""); status != http.StatusUnauthorized {
t.Fatalf("status = %d", status)
}
tok, status, _ := h.login("admin", adminPassword, "")
if status != http.StatusOK || tok == "" {
t.Fatalf("status = %d token = %q", status, tok)
}
}
func TestPushPullAndServerWinsConflict(t *testing.T) {
h := newHarness(t)
tok, _, _ := h.login("admin", adminPassword, "")
db := h.makeDB(tok, "Team")
status, body, hdr := h.push(tok, db, `"0"`, []byte("kdbx-v1"))
if status != http.StatusOK || body["version"].(float64) != 1 {
t.Fatalf("first push: %d %v", status, body)
}
if hdr.Get("ETag") != `"1"` {
t.Fatalf("etag = %q", hdr.Get("ETag"))
}
// A second client that still believes it is on version 0 is refused, and
// learns both the current version and the policy to apply.
status, body, hdr = h.push(tok, db, `"0"`, []byte("kdbx-other"))
if status != http.StatusConflict || body["code"] != "conflict" {
t.Fatalf("stale push: %d %v", status, body)
}
if body["current_version"].(float64) != 1 || body["merge_strategy"] != "server-wins" {
t.Fatalf("conflict body = %v", body)
}
if hdr.Get("X-KeepOrg-Strategy") != "server-wins" {
t.Fatalf("strategy header = %q", hdr.Get("X-KeepOrg-Strategy"))
}
// Forcing is refused while the strategy is server-wins.
if status, _, _ = h.push(tok, db, "*", []byte("forced")); status != http.StatusConflict {
t.Fatalf("force under server-wins: %d", status)
}
// Re-pushing identical bytes is a no-op, so a save that changed nothing
// does not burn a version.
status, body, _ = h.push(tok, db, `"1"`, []byte("kdbx-v1"))
if status != http.StatusOK || body["unchanged"] != true || body["version"].(float64) != 1 {
t.Fatalf("idempotent push: %d %v", status, body)
}
// Download returns exactly what was stored.
req, _ := http.NewRequest("GET", h.http.URL+"/api/v1/databases/"+db+"/blob", nil)
req.Header.Set("Authorization", "Bearer "+tok)
resp, err := h.http.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
got, _ := io.ReadAll(resp.Body)
if string(got) != "kdbx-v1" || resp.Header.Get("ETag") != `"1"` {
t.Fatalf("download = %q etag %q", got, resp.Header.Get("ETag"))
}
}
func TestClientWinsAllowsForce(t *testing.T) {
h := newHarness(t)
tok, _, _ := h.login("admin", adminPassword, "")
db := h.makeDB(tok, "Team")
h.push(tok, db, `"0"`, []byte("v1"))
if status, body := h.call("PATCH", "/api/v1/databases/"+db, tok,
map[string]string{"merge_strategy": "client-wins"}); status != http.StatusOK {
t.Fatalf("patch strategy: %d %v", status, body)
}
status, body, _ := h.push(tok, db, "*", []byte("v2-forced"))
if status != http.StatusOK || body["version"].(float64) != 2 {
t.Fatalf("forced push: %d %v", status, body)
}
}
func TestReadOnlyGrantCannotPush(t *testing.T) {
h := newHarness(t)
admin, _, _ := h.login("admin", adminPassword, "")
db := h.makeDB(admin, "Team")
// A child group with a read-only grant, and a member invited into it.
status, g := h.call("POST", "/api/v1/groups", admin,
map[string]string{"name": "readers", "parent_id": "everyone"})
if status != http.StatusCreated {
t.Fatalf("create group: %d %v", status, g)
}
readers := g["id"].(string)
if status, body := h.call("POST", "/api/v1/grants", admin, map[string]string{
"group_id": readers, "database_id": db, "perm": "read"}); status != http.StatusOK {
t.Fatalf("grant: %d %v", status, body)
}
status, inv := h.call("POST", "/api/v1/invites", admin,
map[string]any{"group_id": readers, "role": "member", "ttl_hours": 1, "max_uses": 1})
if status != http.StatusCreated {
t.Fatalf("invite: %d %v", status, inv)
}
token := inv["token"].(string)
form := url.Values{"username": {"reader"}, "password": {"reader-password"},
"password2": {"reader-password"}}
resp, err := h.http.Client().PostForm(h.http.URL+"/invite/"+token, form)
if err != nil {
t.Fatal(err)
}
page, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if !strings.Contains(string(page), "Account created") {
t.Fatalf("invite redemption page: %s", page)
}
rtok, status, _ := h.login("reader", "reader-password", "")
if status != http.StatusOK {
t.Fatalf("reader login: %d", status)
}
// The reader sees the database through the grant on its group...
status, list := h.call("GET", "/api/v1/databases", rtok, nil)
dbs := list["databases"].([]any)
if status != http.StatusOK || len(dbs) != 1 {
t.Fatalf("reader database list: %d %v", status, list)
}
if dbs[0].(map[string]any)["perm"] != "read" {
t.Fatalf("reader perm = %v", dbs[0])
}
// ...but may not write it.
if status, body, _ := h.push(rtok, db, `"0"`, []byte("nope")); status != http.StatusForbidden {
t.Fatalf("reader push: %d %v", status, body)
}
// A second redemption of a single-use invite fails.
resp2, err := h.http.Client().PostForm(h.http.URL+"/invite/"+token, form)
if err != nil {
t.Fatal(err)
}
page2, _ := io.ReadAll(resp2.Body)
resp2.Body.Close()
if !strings.Contains(string(page2), "already been used") {
t.Fatalf("reused invite page: %s", page2)
}
}
func TestEventStreamDeliversPush(t *testing.T) {
h := newHarness(t)
tok, _, _ := h.login("admin", adminPassword, "")
db := h.makeDB(tok, "Team")
req, _ := http.NewRequest("GET", h.http.URL+"/api/v1/events?access_token="+tok, nil)
resp, err := h.http.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
t.Fatalf("content type = %q", ct)
}
lines := make(chan string, 8)
go func() {
sc := bufio.NewScanner(resp.Body)
for sc.Scan() {
lines <- sc.Text()
}
close(lines)
}()
// Wait for the stream to register before pushing.
deadline := time.After(3 * time.Second)
for h.srv.hub.Count() == 0 {
select {
case <-deadline:
t.Fatal("stream never registered")
default:
time.Sleep(5 * time.Millisecond)
}
}
if status, body, _ := h.push(tok, db, `"0"`, []byte("v1")); status != http.StatusOK {
t.Fatalf("push: %d %v", status, body)
}
for {
select {
case l, open := <-lines:
if !open {
t.Fatal("stream closed before the event arrived")
}
if strings.HasPrefix(l, "data: ") {
var ev Event
if err := json.Unmarshal([]byte(strings.TrimPrefix(l, "data: ")), &ev); err != nil {
t.Fatal(err)
}
if ev.Type != EventUpdated || ev.DatabaseID != db || ev.Version != 1 {
t.Fatalf("event = %+v", ev)
}
return
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for the push event")
}
}
}
func TestTOTPEnrollmentThenRequiredAtLogin(t *testing.T) {
h := newHarness(t)
tok, _, _ := h.login("admin", adminPassword, "")
status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil)
if status != http.StatusOK {
t.Fatalf("enroll: %d %v", status, body)
}
sec := body["secret"].(string)
if !strings.HasPrefix(body["uri"].(string), "otpauth://totp/") {
t.Fatalf("uri = %v", body["uri"])
}
code, err := totp.Code(sec, time.Now())
if err != nil {
t.Fatal(err)
}
if status, body = h.call("POST", "/api/v1/auth/totp/confirm", tok,
map[string]string{"code": code}); status != http.StatusOK {
t.Fatalf("confirm: %d %v", status, body)
}
if _, status, body = h.login("admin", adminPassword, ""); status != http.StatusUnauthorized ||
body["code"] != "totp_required" {
t.Fatalf("login without code: %d %v", status, body)
}
code, _ = totp.Code(sec, time.Now())
if _, status, body = h.login("admin", adminPassword, code); status != http.StatusOK {
t.Fatalf("login with code: %d %v", status, body)
}
}
func TestDeviceLoginApprovedInBrowser(t *testing.T) {
h := newHarness(t)
status, start := h.call("POST", "/api/v1/auth/device/start", "", map[string]string{"label": "laptop"})
if status != http.StatusOK {
t.Fatalf("device start: %d %v", status, start)
}
deviceCode := start["device_code"].(string)
userCode := start["user_code"].(string)
if !strings.Contains(start["verification_url"].(string), userCode) {
t.Fatalf("verification url = %v", start["verification_url"])
}
if status, body := h.call("POST", "/api/v1/auth/device/poll", "",
map[string]string{"device_code": deviceCode}); status != http.StatusAccepted {
t.Fatalf("poll before approval: %d %v", status, body)
}
resp, err := h.http.Client().PostForm(h.http.URL+"/device", url.Values{
"code": {userCode}, "username": {"admin"}, "password": {adminPassword}})
if err != nil {
t.Fatal(err)
}
page, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if !strings.Contains(string(page), "Device approved") {
t.Fatalf("approval page: %s", page)
}
status, body := h.call("POST", "/api/v1/auth/device/poll", "",
map[string]string{"device_code": deviceCode})
if status != http.StatusOK || body["token"] == "" {
t.Fatalf("poll after approval: %d %v", status, body)
}
// The issued token works, and the device code is single use.
if status, _ := h.call("GET", "/api/v1/auth/whoami", body["token"].(string), nil); status != http.StatusOK {
t.Fatalf("whoami with device token: %d", status)
}
if status, _ := h.call("POST", "/api/v1/auth/device/poll", "",
map[string]string{"device_code": deviceCode}); status != http.StatusGone {
t.Fatalf("second poll: %d", status)
}
}
func TestNonAdminCannotAdminister(t *testing.T) {
h := newHarness(t)
admin, _, _ := h.login("admin", adminPassword, "")
hash, _ := secret.Hash("outsider-password")
if err := h.srv.Store().Update(func(st *store.State) error {
st.Users["out"] = &store.User{ID: "out", Username: "outsider", PassHash: hash}
return nil
}); err != nil {
t.Fatal(err)
}
db := h.makeDB(admin, "Team")
otok, _, _ := h.login("outsider", "outsider-password", "")
if status, _ := h.call("POST", "/api/v1/databases", otok,
map[string]string{"name": "x", "owner_group_id": "everyone"}); status != http.StatusForbidden {
t.Fatalf("outsider created a database: %d", status)
}
if status, _ := h.call("PATCH", "/api/v1/databases/"+db, otok,
map[string]string{"merge_strategy": "client-wins"}); status != http.StatusForbidden {
t.Fatalf("outsider changed the strategy: %d", status)
}
// Without any grant the database is invisible, not merely unwritable.
if status, _ := h.call("GET", "/api/v1/databases/"+db+"/blob", otok, nil); status != http.StatusNotFound {
t.Fatalf("outsider read the blob: %d", status)
}
if status, _ := h.call("GET", "/api/v1/databases", "", nil); status != http.StatusUnauthorized {
t.Fatalf("anonymous list: %d", status)
}
}
+292
View File
@@ -0,0 +1,292 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/store"
)
// OIDCConfig configures single sign-on. The server acts as a confidential
// client: it exchanges the authorization code over TLS directly with the
// provider's token endpoint and then reads the identity from the userinfo
// endpoint, so no local JWT verification is needed.
type OIDCConfig struct {
Issuer string `json:"issuer"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
Scopes string `json:"scopes"`
// AutoCreate provisions an account on first successful sign-in.
AutoCreate bool `json:"auto_create"`
// DefaultGroupID is the group auto-created users join.
DefaultGroupID string `json:"default_group_id"`
}
func (c OIDCConfig) Enabled() bool {
return c.Issuer != "" && c.ClientID != "" && c.ClientSecret != ""
}
type oidcMetadata struct {
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserinfoEndpoint string `json:"userinfo_endpoint"`
}
type oidcProvider struct {
cfg OIDCConfig
redirectURI string
http *http.Client
mu sync.Mutex
meta *oidcMetadata
}
func newOIDCProvider(cfg OIDCConfig, redirectURI string) *oidcProvider {
if cfg.Scopes == "" {
cfg.Scopes = "openid profile email"
}
return &oidcProvider{cfg: cfg, redirectURI: redirectURI,
http: &http.Client{Timeout: 15 * time.Second}}
}
// metadata fetches and caches the provider's discovery document.
func (p *oidcProvider) metadata() (*oidcMetadata, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.meta != nil {
return p.meta, nil
}
u := strings.TrimRight(p.cfg.Issuer, "/") + "/.well-known/openid-configuration"
resp, err := p.http.Get(u)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("oidc: discovery %s: %s", u, resp.Status)
}
var m oidcMetadata
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&m); err != nil {
return nil, err
}
if m.AuthorizationEndpoint == "" || m.TokenEndpoint == "" || m.UserinfoEndpoint == "" {
return nil, errors.New("oidc: discovery document is missing endpoints")
}
p.meta = &m
return p.meta, nil
}
type oidcIdentity struct {
Subject string `json:"sub"`
Email string `json:"email"`
Username string `json:"preferred_username"`
Name string `json:"name"`
}
// exchange turns an authorization code into the caller's identity.
func (p *oidcProvider) exchange(code string) (*oidcIdentity, error) {
meta, err := p.metadata()
if err != nil {
return nil, err
}
form := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {p.redirectURI},
}
req, err := http.NewRequest(http.MethodPost, meta.TokenEndpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(url.QueryEscape(p.cfg.ClientID), url.QueryEscape(p.cfg.ClientSecret))
resp, err := p.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<12))
return nil, fmt.Errorf("oidc: token endpoint: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
var tok struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&tok); err != nil {
return nil, err
}
if tok.AccessToken == "" {
return nil, errors.New("oidc: token response carried no access token")
}
return p.userinfo(meta.UserinfoEndpoint, tok.AccessToken)
}
func (p *oidcProvider) userinfo(endpoint, accessToken string) (*oidcIdentity, error) {
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := p.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("oidc: userinfo: %s", resp.Status)
}
var id oidcIdentity
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&id); err != nil {
return nil, err
}
if id.Subject == "" {
return nil, errors.New("oidc: userinfo carried no subject")
}
return &id, nil
}
// handleOIDCLogin starts the authorization code flow for a pending device.
func (s *Server) handleOIDCLogin(w http.ResponseWriter, r *http.Request) {
if s.oidc == nil {
s.renderDone(w, "Single sign-on unavailable", "This server has no OIDC provider configured.")
return
}
meta, err := s.oidc.metadata()
if err != nil {
s.log.Printf("oidc discovery: %v", err)
s.renderDone(w, "Single sign-on unavailable", "The identity provider could not be reached.")
return
}
userCode := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("code")))
state := secret.Token()
if err := s.st.Update(func(st *store.State) error {
d := st.DeviceByUserCode(userCode)
if d == nil || time.Now().After(d.ExpiresAt) {
return errors.New("unknown or expired device code")
}
d.OIDCState = state
return nil
}); err != nil {
s.renderDevice(w, userCode, err.Error())
return
}
q := url.Values{
"response_type": {"code"},
"client_id": {s.oidc.cfg.ClientID},
"redirect_uri": {s.oidc.redirectURI},
"scope": {s.oidc.cfg.Scopes},
"state": {state},
}
http.Redirect(w, r, meta.AuthorizationEndpoint+"?"+q.Encode(), http.StatusFound)
}
// handleOIDCCallback finishes the flow and approves the waiting device.
func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
if s.oidc == nil {
s.renderDone(w, "Single sign-on unavailable", "This server has no OIDC provider configured.")
return
}
q := r.URL.Query()
if e := q.Get("error"); e != "" {
s.renderDone(w, "Sign-in failed", e)
return
}
state, code := q.Get("state"), q.Get("code")
if state == "" || code == "" {
s.renderDone(w, "Sign-in failed", "The provider did not return a code.")
return
}
// Bind the state to a pending device before spending the code.
var found bool
s.st.Read(func(st *store.State) {
for _, d := range st.Devices {
if d.OIDCState != "" && secret.Equal(d.OIDCState, state) && time.Now().Before(d.ExpiresAt) {
found = true
}
}
})
if !found {
s.renderDone(w, "Sign-in failed", "This sign-in request has expired. Start again from the client.")
return
}
id, err := s.oidc.exchange(code)
if err != nil {
s.log.Printf("oidc exchange: %v", err)
s.renderDone(w, "Sign-in failed", "The identity provider rejected the sign-in.")
return
}
if err := s.st.Update(func(st *store.State) error {
var dev *store.DeviceAuth
for _, d := range st.Devices {
if d.OIDCState != "" && secret.Equal(d.OIDCState, state) {
dev = d
}
}
if dev == nil || time.Now().After(dev.ExpiresAt) {
return errors.New("this sign-in request has expired")
}
u, err := s.linkOIDCUser(st, id)
if err != nil {
return err
}
dev.OIDCState = ""
dev.SessionToken = s.newSession(st, u.ID, dev.Label).Token
return nil
}); err != nil {
s.renderDone(w, "Sign-in failed", err.Error())
return
}
s.renderDone(w, "Device approved", "You can close this page and return to the application.")
}
// linkOIDCUser finds the account behind an identity, provisioning one when
// the deployment allows it.
func (s *Server) linkOIDCUser(st *store.State, id *oidcIdentity) (*store.User, error) {
issuer := strings.TrimRight(s.oidc.cfg.Issuer, "/")
if u := st.UserByOIDC(issuer, id.Subject); u != nil {
if u.Disabled {
return nil, errors.New("this account is disabled")
}
return u, nil
}
// A local account may claim the identity by matching username or email,
// which is how an invited user upgrades to single sign-on.
for _, u := range st.Users {
if u.OIDCSubject != "" {
continue
}
if (id.Username != "" && u.Username == id.Username) ||
(id.Email != "" && strings.EqualFold(u.Email, id.Email)) {
u.OIDCIssuer, u.OIDCSubject = issuer, id.Subject
return u, nil
}
}
if !s.oidc.cfg.AutoCreate {
return nil, errors.New("no account is linked to this identity; redeem an invite link first")
}
name := id.Username
if name == "" {
name = id.Email
}
if name == "" {
name = "sso-" + secret.ID()
}
if validUsername(name) != nil || st.UserByName(name) != nil {
name = "sso-" + secret.ID()
}
u := &store.User{ID: secret.ID(), Username: name, Email: id.Email,
OIDCIssuer: issuer, OIDCSubject: id.Subject, CreatedAt: time.Now()}
st.Users[u.ID] = u
if g := s.oidc.cfg.DefaultGroupID; g != "" && st.Groups[g] != nil {
st.Memberships = append(st.Memberships, store.Membership{UserID: u.ID, GroupID: g, Role: store.RoleMember})
}
return u, nil
}
+84
View File
@@ -0,0 +1,84 @@
package server
import (
"html/template"
"net/http"
)
// The server has no browsable UI. These three pages exist only because a
// browser is the right place to approve a device, redeem an invite and run an
// OIDC round trip.
var pages = template.Must(template.New("layout").Parse(`
{{define "layout"}}<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.Title}} &middot; KeepOrg</title>
<style>
:root{color-scheme:light dark;--fg:#1a1a1a;--bg:#fafafa;--card:#fff;--line:#d8d8d8;--accent:#2d6a4f}
@media(prefers-color-scheme:dark){:root{--fg:#e8e8e8;--bg:#161616;--card:#1f1f1f;--line:#3a3a3a;--accent:#74c69d}}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--bg);color:var(--fg);
font:16px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;padding:24px}
main{width:100%;max-width:26rem;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:28px}
h1{font-size:1.25rem;margin:0 0 .25rem}
p.sub{margin:0 0 1.25rem;opacity:.7;font-size:.9rem}
label{display:block;font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;opacity:.7;margin:.9rem 0 .25rem}
input{width:100%;padding:.6rem .7rem;font:inherit;color:var(--fg);background:transparent;
border:1px solid var(--line);border-radius:8px}
button{width:100%;margin-top:1.4rem;padding:.7rem;font:inherit;font-weight:600;color:#fff;
background:var(--accent);border:0;border-radius:8px;cursor:pointer}
.err{margin:0 0 1rem;padding:.6rem .7rem;border-radius:8px;background:#f8d7da;color:#842029;font-size:.9rem}
.alt{display:block;margin-top:1rem;text-align:center;font-size:.9rem;color:var(--accent)}
code{font-size:1.1rem;letter-spacing:.08em}
</style></head><body><main>{{template "body" .}}</main></body></html>{{end}}
`))
var deviceTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}}<h1>Approve this device</h1>
<p class="sub">Sign in to let the client on your device sync your databases.</p>
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
<form method="post" action="/device">
<label for="code">Device code</label><input id="code" name="code" value="{{.Code}}" required autocapitalize="characters">
<label for="username">Username</label><input id="username" name="username" autocomplete="username" required>
<label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required>
<label for="totp">One-time code <small>(if enabled)</small></label><input id="totp" name="totp" inputmode="numeric" autocomplete="one-time-code">
<button type="submit">Approve</button>
</form>
{{if .OIDC}}<a class="alt" href="/oidc/login?code={{.Code}}">Sign in with single sign-on &rarr;</a>{{end}}
{{end}}`))
var inviteTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}}<h1>Create your account</h1>
<p class="sub">You were invited to a KeepOrg server.</p>
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
<form method="post" action="/invite/{{.Token}}">
<label for="username">Username</label><input id="username" name="username" autocomplete="username" required>
<label for="email">Email <small>(optional)</small></label><input id="email" name="email" type="email" autocomplete="email">
<label for="password">Password</label><input id="password" name="password" type="password" autocomplete="new-password" minlength="10" required>
<label for="password2">Repeat password</label><input id="password2" name="password2" type="password" autocomplete="new-password" minlength="10" required>
<button type="submit">Create account</button>
</form>
{{end}}`))
var doneTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}}<h1>{{.Title}}</h1><p class="sub">{{.Message}}</p>{{end}}`))
func (s *Server) renderDevice(w http.ResponseWriter, code, errMsg string) {
render(w, deviceTmpl, map[string]any{
"Title": "Approve device", "Code": code, "Error": errMsg, "OIDC": s.oidc != nil,
})
}
func (s *Server) renderInvite(w http.ResponseWriter, token, errMsg string) {
render(w, inviteTmpl, map[string]any{"Title": "Create account", "Token": token, "Error": errMsg})
}
func (s *Server) renderDone(w http.ResponseWriter, title, msg string) {
render(w, doneTmpl, map[string]any{"Title": title, "Message": msg})
}
func render(w http.ResponseWriter, t *template.Template, data map[string]any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Referrer-Policy", "no-referrer")
_ = t.ExecuteTemplate(w, "layout", data)
}
+253
View File
@@ -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(),
})
}
+284
View File
@@ -0,0 +1,284 @@
package server
import (
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"cc.tiredsysadmin.keeporg/internal/blob"
"cc.tiredsysadmin.keeporg/internal/store"
)
// dbView is what a client learns about a database it has access to.
type dbView struct {
ID string `json:"id"`
Name string `json:"name"`
OwnerGroupID string `json:"owner_group_id"`
Perm store.Perm `json:"perm"`
MergeStrategy store.Strategy `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,omitempty"`
}
func toView(db *store.Database, p store.Perm) dbView {
return dbView{ID: db.ID, Name: db.Name, OwnerGroupID: db.OwnerGroupID, Perm: p,
MergeStrategy: db.MergeStrategy, Version: db.Version, SHA256: db.SHA256,
Size: db.Size, UpdatedAt: db.UpdatedAt, UpdatedBy: db.UpdatedBy}
}
func (s *Server) handleListDatabases(w http.ResponseWriter, r *http.Request, u *store.User) {
out := []dbView{}
s.st.Read(func(st *store.State) {
for _, a := range st.Assignments(u.ID) {
out = append(out, toView(a.DB, a.Perm))
}
})
writeJSON(w, http.StatusOK, map[string]any{"databases": out})
}
// handleGetBlob serves the current version of a database, or a historical one
// when ?version= is given.
func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request, u *store.User) {
id := r.PathValue("id")
var (
db store.Database
perm store.Perm
)
s.st.Read(func(st *store.State) {
perm = st.DatabasePerm(u.ID, id)
if d := st.Databases[id]; d != nil {
db = *d
}
})
if !perm.AtLeast(store.PermRead) {
fail(w, http.StatusNotFound, "not_found", "no such database")
return
}
version := db.Version
if q := r.URL.Query().Get("version"); q != "" {
v, err := strconv.ParseInt(q, 10, 64)
if err != nil {
fail(w, http.StatusBadRequest, "bad_request", "version must be an integer")
return
}
version = v
}
if version == 0 {
// A database that has never been written has no bytes yet.
w.Header().Set("ETag", `"0"`)
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusNoContent)
return
}
data, err := s.blobs.Get(id, version)
if err != nil {
fail(w, http.StatusNotFound, "not_found", "that version is no longer stored")
return
}
w.Header().Set("ETag", etag(version))
w.Header().Set("X-KeepOrg-SHA256", blob.SHA256(data))
w.Header().Set("X-KeepOrg-Version", strconv.FormatInt(version, 10))
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", db.Name+".kdbx"))
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
_, _ = w.Write(data)
}
// handlePutBlob stores a new version. The client states the version it edited
// in If-Match; the stored merge strategy decides what happens if that is no
// longer current.
func (s *Server) handlePutBlob(w http.ResponseWriter, r *http.Request, u *store.User) {
id := r.PathValue("id")
var (
perm store.Perm
current int64
strategy store.Strategy
exists bool
)
s.st.Read(func(st *store.State) {
perm = st.DatabasePerm(u.ID, id)
if d := st.Databases[id]; d != nil {
exists, current, strategy = true, d.Version, d.MergeStrategy
}
})
if !exists || !perm.AtLeast(store.PermRead) {
fail(w, http.StatusNotFound, "not_found", "no such database")
return
}
if !perm.AtLeast(store.PermWrite) {
fail(w, http.StatusForbidden, "forbidden", "you have read-only access to this database")
return
}
v := decidePush(current, r.Header.Get("If-Match"), strategy)
if !v.Accept {
w.Header().Set("ETag", etag(current))
w.Header().Set("X-KeepOrg-Strategy", string(strategy))
writeJSON(w, v.Status, map[string]any{
"code": v.Code, "error": v.Message,
"current_version": current, "merge_strategy": strategy,
})
return
}
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, s.cfg.MaxDBSize))
if err != nil {
fail(w, http.StatusRequestEntityTooLarge, "too_large",
fmt.Sprintf("database exceeds the %d byte limit", s.cfg.MaxDBSize))
return
}
if len(data) == 0 {
fail(w, http.StatusBadRequest, "bad_request", "empty body")
return
}
sum := blob.SHA256(data)
if want := r.Header.Get("X-KeepOrg-SHA256"); want != "" && !strings.EqualFold(want, sum) {
fail(w, http.StatusBadRequest, "checksum_mismatch", "body does not match the declared digest")
return
}
var (
next int64
unchanged bool
watchers []string
name string
)
err = s.st.Update(func(st *store.State) error {
d := st.Databases[id]
if d == nil {
return errors.New("no such database")
}
// Re-check under the write lock: a concurrent push may have landed.
if vv := decidePush(d.Version, r.Header.Get("If-Match"), d.MergeStrategy); !vv.Accept {
return conflictError{current: d.Version, strategy: d.MergeStrategy, verdict: vv}
}
if d.SHA256 == sum {
next, unchanged, name = d.Version, true, d.Name
return nil
}
next = d.Version + 1
if err := s.blobs.Put(id, next, data); err != nil {
return err
}
d.Version, d.SHA256, d.Size = next, sum, int64(len(data))
d.UpdatedAt, d.UpdatedBy = time.Now(), u.Username
name = d.Name
watchers = st.Watchers(id)
return nil
})
var ce conflictError
switch {
case errors.As(err, &ce):
w.Header().Set("ETag", etag(ce.current))
w.Header().Set("X-KeepOrg-Strategy", string(ce.strategy))
writeJSON(w, ce.verdict.Status, map[string]any{
"code": ce.verdict.Code, "error": ce.verdict.Message,
"current_version": ce.current, "merge_strategy": ce.strategy,
})
return
case err != nil:
fail(w, http.StatusInternalServerError, "internal", err.Error())
return
}
if !unchanged {
_ = s.blobs.Prune(id)
s.hub.Publish(watchers, Event{Type: EventUpdated, DatabaseID: id, Name: name,
Version: next, SHA256: sum, By: u.Username})
}
w.Header().Set("ETag", etag(next))
writeJSON(w, http.StatusOK, map[string]any{
"version": next, "sha256": sum, "unchanged": unchanged,
})
}
// handleEvents streams database updates to one client over SSE. SSE is enough
// here because the channel only ever carries server-to-client notifications.
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request, u *store.User) {
flusher, ok := w.(http.Flusher)
if !ok {
fail(w, http.StatusInternalServerError, "internal", "streaming unsupported")
return
}
id, ch := s.hub.Subscribe(u.ID)
defer s.hub.Unsubscribe(id)
h := w.Header()
h.Set("Content-Type", "text/event-stream")
h.Set("Cache-Control", "no-store")
h.Set("Connection", "keep-alive")
h.Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "retry: 3000\n\n")
flusher.Flush()
keepalive := time.NewTicker(25 * time.Second)
defer keepalive.Stop()
for {
select {
case <-r.Context().Done():
return
case ev, open := <-ch:
if !open {
return
}
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, ev.encode())
flusher.Flush()
case <-keepalive.C:
fmt.Fprint(w, ": keepalive\n\n")
flusher.Flush()
}
}
}
// ---- the push decision, kept pure ----
// verdict is the outcome of comparing a push against the stored version.
type verdict struct {
Accept bool
Status int
Code string
Message string
}
type conflictError struct {
current int64
strategy store.Strategy
verdict verdict
}
func (e conflictError) Error() string { return e.verdict.Message }
// decidePush is the whole merge policy. The client sends the version it based
// its edit on in If-Match; If-Match: * means "I know I am stale and I intend
// to win", which only a client-wins database allows.
func decidePush(current int64, ifMatch string, strategy store.Strategy) verdict {
ifMatch = strings.TrimSpace(ifMatch)
switch {
case ifMatch == "":
return verdict{Status: http.StatusPreconditionRequired, Code: "precondition_required",
Message: "If-Match must carry the version this edit was based on"}
case ifMatch == "*":
if strategy == store.StrategyClientWins {
return verdict{Accept: true}
}
return verdict{Status: http.StatusConflict, Code: "force_not_allowed",
Message: "this database does not allow a client to overwrite the server copy"}
}
v, err := strconv.ParseInt(strings.Trim(ifMatch, `"`), 10, 64)
if err != nil {
return verdict{Status: http.StatusBadRequest, Code: "bad_request",
Message: "If-Match must be a version number or *"}
}
if v == current {
return verdict{Accept: true}
}
return verdict{Status: http.StatusConflict, Code: "conflict",
Message: fmt.Sprintf("the server is at version %d, this edit was based on %d", current, v)}
}
func etag(v int64) string { return `"` + strconv.FormatInt(v, 10) + `"` }
+49
View File
@@ -0,0 +1,49 @@
package server
import (
"net/http"
"testing"
"cc.tiredsysadmin.keeporg/internal/store"
)
func TestDecidePushRequiresIfMatch(t *testing.T) {
v := decidePush(3, "", store.StrategyServerWins)
if v.Accept || v.Status != http.StatusPreconditionRequired {
t.Fatalf("verdict = %+v", v)
}
}
func TestDecidePushAcceptsCurrentVersion(t *testing.T) {
for _, m := range []string{`"3"`, "3", ` "3" `} {
if v := decidePush(3, m, store.StrategyServerWins); !v.Accept {
t.Fatalf("If-Match %q rejected: %+v", m, v)
}
}
}
func TestDecidePushRejectsStaleVersion(t *testing.T) {
v := decidePush(4, `"3"`, store.StrategyServerWins)
if v.Accept || v.Status != http.StatusConflict || v.Code != "conflict" {
t.Fatalf("verdict = %+v", v)
}
}
func TestForceOnlyAllowedForClientWins(t *testing.T) {
if v := decidePush(4, "*", store.StrategyClientWins); !v.Accept {
t.Fatalf("client-wins force rejected: %+v", v)
}
for _, s := range []store.Strategy{store.StrategyServerWins, store.StrategyManual} {
v := decidePush(4, "*", s)
if v.Accept || v.Code != "force_not_allowed" {
t.Fatalf("force under %s = %+v", s, v)
}
}
}
func TestDecidePushRejectsJunkIfMatch(t *testing.T) {
v := decidePush(1, `"abc"`, store.StrategyServerWins)
if v.Accept || v.Status != http.StatusBadRequest {
t.Fatalf("verdict = %+v", v)
}
}
+143
View File
@@ -0,0 +1,143 @@
package store
import "sort"
// maxDepth bounds every tree walk so a corrupted parent cycle cannot hang the
// server. Group creation rejects cycles, so this is a backstop only.
const maxDepth = 64
// Ancestors returns groupID and every parent above it, nearest first.
func (s *State) Ancestors(groupID string) []string {
out := []string{}
seen := map[string]bool{}
for id := groupID; id != "" && !seen[id] && len(out) < maxDepth; {
g, ok := s.Groups[id]
if !ok {
break
}
seen[id] = true
out = append(out, id)
id = g.ParentID
}
return out
}
// Descendants returns groupID and every group below it.
func (s *State) Descendants(groupID string) []string {
if _, ok := s.Groups[groupID]; !ok {
return nil
}
out := []string{groupID}
for i := 0; i < len(out); i++ {
for id, g := range s.Groups {
if g.ParentID == out[i] && !contains(out, id) {
out = append(out, id)
}
}
}
sort.Strings(out)
return out
}
// IsAncestor reports whether anc is at or above desc in the tree.
func (s *State) IsAncestor(anc, desc string) bool {
return contains(s.Ancestors(desc), anc)
}
// EffectiveGroups is the set of groups whose grants reach the user: every
// group the user belongs to, plus every group above those. A grant on
// "Company" therefore reaches a member of "Company/Engineering/Backend".
func (s *State) EffectiveGroups(userID string) map[string]bool {
out := map[string]bool{}
for _, m := range s.Memberships {
if m.UserID != userID {
continue
}
for _, id := range s.Ancestors(m.GroupID) {
out[id] = true
}
}
return out
}
// IsGroupAdmin reports whether the user administers groupID, either directly
// or by administering a group above it. Server admins administer everything.
func (s *State) IsGroupAdmin(userID, groupID string) bool {
if u := s.Users[userID]; u != nil && u.ServerAdmin {
return true
}
anc := s.Ancestors(groupID)
for _, m := range s.Memberships {
if m.UserID == userID && m.Role == RoleAdmin && contains(anc, m.GroupID) {
return true
}
}
return false
}
// DatabasePerm is the strongest permission the user holds on a database.
func (s *State) DatabasePerm(userID, dbID string) Perm {
db, ok := s.Databases[dbID]
if !ok {
return PermNone
}
if s.IsGroupAdmin(userID, db.OwnerGroupID) {
return PermAdmin
}
reach := s.EffectiveGroups(userID)
best := PermNone
for _, g := range s.Grants {
if g.DatabaseID != dbID || !reach[g.GroupID] {
continue
}
if g.Perm.Rank() > best.Rank() {
best = g.Perm
}
}
return best
}
// Assignment pairs a database with the caller's permission on it.
type Assignment struct {
DB *Database
Perm Perm
}
// Assignments lists every database the user may see, strongest permission
// first resolved, sorted by name for a stable client view.
func (s *State) Assignments(userID string) []Assignment {
out := []Assignment{}
for id, db := range s.Databases {
if p := s.DatabasePerm(userID, id); p != PermNone {
out = append(out, Assignment{DB: db, Perm: p})
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].DB.Name != out[j].DB.Name {
return out[i].DB.Name < out[j].DB.Name
}
return out[i].DB.ID < out[j].DB.ID
})
return out
}
// Watchers lists the user IDs that must be told when a database changes.
func (s *State) Watchers(dbID string) []string {
out := []string{}
for id := range s.Users {
if s.DatabasePerm(id, dbID) != PermNone {
out = append(out, id)
}
}
sort.Strings(out)
return out
}
func contains(ss []string, s string) bool {
for _, v := range ss {
if v == s {
return true
}
}
return false
}
+90
View File
@@ -0,0 +1,90 @@
package store
import "testing"
// tree: company > engineering > backend, company > sales
func aclFixture() *State {
s := newState()
s.Groups["company"] = &Group{ID: "company", Name: "Company"}
s.Groups["eng"] = &Group{ID: "eng", Name: "Engineering", ParentID: "company"}
s.Groups["backend"] = &Group{ID: "backend", Name: "Backend", ParentID: "eng"}
s.Groups["sales"] = &Group{ID: "sales", Name: "Sales", ParentID: "company"}
s.Users["alice"] = &User{ID: "alice", Username: "alice"}
s.Users["bob"] = &User{ID: "bob", Username: "bob"}
s.Users["root"] = &User{ID: "root", Username: "root", ServerAdmin: true}
s.Memberships = []Membership{
{UserID: "alice", GroupID: "backend", Role: RoleMember},
{UserID: "bob", GroupID: "sales", Role: RoleAdmin},
}
s.Databases["shared"] = &Database{ID: "shared", Name: "Shared", OwnerGroupID: "company"}
s.Databases["salesdb"] = &Database{ID: "salesdb", Name: "Sales", OwnerGroupID: "sales"}
s.Grants = []Grant{
{GroupID: "company", DatabaseID: "shared", Perm: PermRead},
{GroupID: "eng", DatabaseID: "shared", Perm: PermWrite},
}
return s
}
func TestGrantOnAncestorReachesDescendantMember(t *testing.T) {
s := aclFixture()
if got := s.DatabasePerm("alice", "shared"); got != PermWrite {
t.Fatalf("alice on shared = %q, want write (read from company, write from eng)", got)
}
}
func TestGrantDoesNotLeakAcrossSiblings(t *testing.T) {
s := aclFixture()
if got := s.DatabasePerm("alice", "salesdb"); got != PermNone {
t.Fatalf("alice on salesdb = %q, want none", got)
}
if got := s.DatabasePerm("bob", "shared"); got != PermRead {
t.Fatalf("bob on shared = %q, want read", got)
}
}
func TestOwnerGroupAdminGetsAdmin(t *testing.T) {
s := aclFixture()
if got := s.DatabasePerm("bob", "salesdb"); got != PermAdmin {
t.Fatalf("bob on own group db = %q, want admin", got)
}
if !s.IsGroupAdmin("root", "backend") {
t.Fatal("server admin should administer any group")
}
if s.IsGroupAdmin("bob", "eng") {
t.Fatal("sales admin must not administer engineering")
}
}
func TestAdminOfParentAdministersChild(t *testing.T) {
s := aclFixture()
s.Memberships = append(s.Memberships, Membership{UserID: "alice", GroupID: "company", Role: RoleAdmin})
if !s.IsGroupAdmin("alice", "backend") {
t.Fatal("company admin should administer backend")
}
if got := s.DatabasePerm("alice", "salesdb"); got != PermAdmin {
t.Fatalf("company admin on salesdb = %q, want admin", got)
}
}
func TestAssignmentsAndWatchers(t *testing.T) {
s := aclFixture()
got := s.Assignments("alice")
if len(got) != 1 || got[0].DB.ID != "shared" || got[0].Perm != PermWrite {
t.Fatalf("alice assignments = %+v", got)
}
w := s.Watchers("shared")
if len(w) != 3 {
t.Fatalf("watchers of shared = %v, want alice, bob and root", w)
}
}
func TestTreeWalksSurviveCycle(t *testing.T) {
s := aclFixture()
s.Groups["company"].ParentID = "backend" // corrupt
if len(s.Ancestors("backend")) != 3 {
t.Fatalf("cycle walk = %v", s.Ancestors("backend"))
}
if len(s.Descendants("company")) == 0 {
t.Fatal("descendants must terminate on a cycle")
}
}
+223
View File
@@ -0,0 +1,223 @@
// Package store holds the server's authoritative metadata: users, nested
// groups, databases and the grants that connect them.
//
// Glossary:
//
// User an account, authenticated by password+TOTP or by OIDC.
// Group a node in a single tree of groups. Grants hang off groups.
// Membership a user's place in one group, as member or admin.
// Database one KDBX file tracked by the server. Owned by one group.
// Grant a (group, database, permission) triple.
// Invite a single- or multi-use registration token.
// Session a bearer token held by a client device.
package store
import "time"
// Perm is a permission on a database, ordered read < write < admin.
type Perm string
const (
PermNone Perm = ""
PermRead Perm = "read"
PermWrite Perm = "write"
PermAdmin Perm = "admin"
)
var permRank = map[Perm]int{PermNone: 0, PermRead: 1, PermWrite: 2, PermAdmin: 3}
// Rank orders permissions so the strongest of several grants can be picked.
func (p Perm) Rank() int { return permRank[p] }
// AtLeast reports whether p is as strong as want.
func (p Perm) AtLeast(want Perm) bool { return p.Rank() >= want.Rank() }
// Valid reports whether p is a permission that may be stored.
func (p Perm) Valid() bool { return p == PermRead || p == PermWrite || p == PermAdmin }
// Role is a user's role inside a group.
type Role string
const (
RoleMember Role = "member"
RoleAdmin Role = "admin"
)
// Valid reports whether r is a role that may be stored.
func (r Role) Valid() bool { return r == RoleMember || r == RoleAdmin }
// Strategy decides what happens when a client pushes a database version that
// is no longer current.
type Strategy string
const (
// StrategyServerWins rejects the stale push; the client takes the server
// copy and keeps its own as a conflict file. This is the default.
StrategyServerWins Strategy = "server-wins"
// StrategyClientWins lets the client overwrite the server copy.
StrategyClientWins Strategy = "client-wins"
// StrategyManual rejects the stale push and leaves both copies in place
// for a human to reconcile.
StrategyManual Strategy = "manual"
)
// Valid reports whether s is a strategy that may be stored.
func (s Strategy) Valid() bool {
return s == StrategyServerWins || s == StrategyClientWins || s == StrategyManual
}
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
PassHash string `json:"pass_hash,omitempty"`
OIDCIssuer string `json:"oidc_issuer,omitempty"`
OIDCSubject string `json:"oidc_subject,omitempty"`
TOTPSecret string `json:"totp_secret,omitempty"`
TOTPConfirmed bool `json:"totp_confirmed"`
ServerAdmin bool `json:"server_admin"`
Disabled bool `json:"disabled"`
CreatedAt time.Time `json:"created_at"`
}
type Group struct {
ID string `json:"id"`
Name string `json:"name"`
ParentID string `json:"parent_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type Membership struct {
UserID string `json:"user_id"`
GroupID string `json:"group_id"`
Role Role `json:"role"`
}
type Database struct {
ID string `json:"id"`
Name string `json:"name"`
OwnerGroupID string `json:"owner_group_id"`
MergeStrategy Strategy `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,omitempty"`
}
type Grant struct {
GroupID string `json:"group_id"`
DatabaseID string `json:"database_id"`
Perm Perm `json:"perm"`
}
type Invite struct {
Token string `json:"token"`
GroupID string `json:"group_id,omitempty"`
Role Role `json:"role"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
MaxUses int `json:"max_uses"`
Uses int `json:"uses"`
}
// Usable reports whether the invite may still be redeemed at time now.
func (i *Invite) Usable(now time.Time) bool {
return now.Before(i.ExpiresAt) && (i.MaxUses <= 0 || i.Uses < i.MaxUses)
}
type Session struct {
Token string `json:"token"`
UserID string `json:"user_id"`
Label string `json:"label,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
// DeviceAuth backs the browser-assisted login used for OIDC on desktop and
// Android: the client shows a short user code, the browser approves it, the
// client polls until a session token appears.
type DeviceAuth struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
Label string `json:"label,omitempty"`
SessionToken string `json:"session_token,omitempty"`
OIDCState string `json:"oidc_state,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
// State is the whole metadata set, serialized as one JSON document.
type State struct {
Users map[string]*User `json:"users"`
Groups map[string]*Group `json:"groups"`
Databases map[string]*Database `json:"databases"`
Invites map[string]*Invite `json:"invites"`
Sessions map[string]*Session `json:"sessions"`
Devices map[string]*DeviceAuth `json:"devices"`
Memberships []Membership `json:"memberships"`
Grants []Grant `json:"grants"`
}
func newState() *State {
return &State{
Users: map[string]*User{},
Groups: map[string]*Group{},
Databases: map[string]*Database{},
Invites: map[string]*Invite{},
Sessions: map[string]*Session{},
Devices: map[string]*DeviceAuth{},
}
}
func (s *State) init() {
if s.Users == nil {
s.Users = map[string]*User{}
}
if s.Groups == nil {
s.Groups = map[string]*Group{}
}
if s.Databases == nil {
s.Databases = map[string]*Database{}
}
if s.Invites == nil {
s.Invites = map[string]*Invite{}
}
if s.Sessions == nil {
s.Sessions = map[string]*Session{}
}
if s.Devices == nil {
s.Devices = map[string]*DeviceAuth{}
}
}
// UserByName finds a user by exact username.
func (s *State) UserByName(name string) *User {
for _, u := range s.Users {
if u.Username == name {
return u
}
}
return nil
}
// UserByOIDC finds a user previously linked to an OIDC issuer and subject.
func (s *State) UserByOIDC(issuer, subject string) *User {
for _, u := range s.Users {
if u.OIDCIssuer == issuer && u.OIDCSubject == subject {
return u
}
}
return nil
}
// DeviceByUserCode finds a pending device login by the code shown to the user.
func (s *State) DeviceByUserCode(code string) *DeviceAuth {
for _, d := range s.Devices {
if d.UserCode == code {
return d
}
}
return nil
}
+136
View File
@@ -0,0 +1,136 @@
package store
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// Store is the single writer of the metadata document. Every mutation runs on
// a private copy of the state and is swapped in only once it has reached the
// disk, so a reader never observes a half-applied update and a crash never
// leaves a partial file.
type Store struct {
path string
mu sync.RWMutex
s *State
}
// Open loads the state document at path, creating an empty one if absent.
func Open(path string) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
st := &Store{path: path, s: newState()}
b, err := os.ReadFile(path)
switch {
case os.IsNotExist(err):
return st, st.persist(st.s)
case err != nil:
return nil, err
}
s := newState()
if err := json.Unmarshal(b, s); err != nil {
return nil, fmt.Errorf("store: parse %s: %w", path, err)
}
s.init()
st.s = s
return st, nil
}
// Read runs fn against a consistent view of the state. fn must not retain or
// mutate anything it is given.
func (st *Store) Read(fn func(*State)) {
st.mu.RLock()
defer st.mu.RUnlock()
fn(st.s)
}
// Update applies fn to a copy of the state and commits it. If fn or the write
// fails, the stored state is left untouched.
func (st *Store) Update(fn func(*State) error) error {
st.mu.Lock()
defer st.mu.Unlock()
next, err := clone(st.s)
if err != nil {
return err
}
if err := fn(next); err != nil {
return err
}
if err := st.persist(next); err != nil {
return err
}
st.s = next
return nil
}
// GC drops expired sessions, device logins and invites.
func (st *Store) GC(now time.Time) error {
return st.Update(func(s *State) error {
for k, v := range s.Sessions {
if now.After(v.ExpiresAt) {
delete(s.Sessions, k)
}
}
for k, v := range s.Devices {
if now.After(v.ExpiresAt) {
delete(s.Devices, k)
}
}
for k, v := range s.Invites {
if !v.Usable(now) {
delete(s.Invites, k)
}
}
return nil
})
}
func (st *Store) persist(s *State) error {
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
return writeFileAtomic(st.path, b, 0o600)
}
func clone(s *State) (*State, error) {
b, err := json.Marshal(s)
if err != nil {
return nil, err
}
out := newState()
if err := json.Unmarshal(b, out); err != nil {
return nil, err
}
out.init()
return out, nil
}
// writeFileAtomic replaces path with data, surviving a crash mid-write.
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
tmp := path + ".tmp"
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := f.Write(data); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Sync(); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}
+76
View File
@@ -0,0 +1,76 @@
package store
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
)
func TestUpdatePersistsAndSurvivesReopen(t *testing.T) {
p := filepath.Join(t.TempDir(), "state.json")
st, err := Open(p)
if err != nil {
t.Fatal(err)
}
if err := st.Update(func(s *State) error {
s.Users["u1"] = &User{ID: "u1", Username: "alice"}
return nil
}); err != nil {
t.Fatal(err)
}
again, err := Open(p)
if err != nil {
t.Fatal(err)
}
var name string
again.Read(func(s *State) { name = s.Users["u1"].Username })
if name != "alice" {
t.Fatalf("reopened username = %q", name)
}
if fi, _ := os.Stat(p); fi.Mode().Perm() != 0o600 {
t.Fatalf("state file mode = %v", fi.Mode().Perm())
}
}
func TestFailedUpdateLeavesStateUntouched(t *testing.T) {
st, err := Open(filepath.Join(t.TempDir(), "state.json"))
if err != nil {
t.Fatal(err)
}
boom := errors.New("boom")
if err := st.Update(func(s *State) error {
s.Users["u1"] = &User{ID: "u1"}
return boom
}); !errors.Is(err, boom) {
t.Fatalf("err = %v", err)
}
var n int
st.Read(func(s *State) { n = len(s.Users) })
if n != 0 {
t.Fatalf("users after failed update = %d", n)
}
}
func TestGCDropsExpired(t *testing.T) {
st, _ := Open(filepath.Join(t.TempDir(), "state.json"))
now := time.Now()
_ = st.Update(func(s *State) error {
s.Sessions["old"] = &Session{Token: "old", ExpiresAt: now.Add(-time.Hour)}
s.Sessions["new"] = &Session{Token: "new", ExpiresAt: now.Add(time.Hour)}
s.Invites["spent"] = &Invite{Token: "spent", ExpiresAt: now.Add(time.Hour), MaxUses: 1, Uses: 1}
return nil
})
if err := st.GC(now); err != nil {
t.Fatal(err)
}
st.Read(func(s *State) {
if len(s.Sessions) != 1 || s.Sessions["new"] == nil {
t.Fatalf("sessions after GC = %v", s.Sessions)
}
if len(s.Invites) != 0 {
t.Fatalf("invites after GC = %v", s.Invites)
}
})
}
+90
View File
@@ -0,0 +1,90 @@
// Package totp implements RFC 6238 time-based one-time passwords, the
// built-in second factor for password logins.
package totp
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"fmt"
"net/url"
"strings"
"time"
)
const (
digits = 6
step = 30 * time.Second
// skew is how many steps either side of now are accepted, covering
// clock drift and a user typing slowly.
skew = 1
)
// NewSecret returns a fresh base32 secret with no padding, as authenticator
// apps expect.
func NewSecret() string {
b := make([]byte, 20)
if _, err := rand.Read(b); err != nil {
panic("totp: " + err.Error())
}
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
}
// URI is the otpauth:// URL an authenticator app can scan.
func URI(issuer, account, secret string) string {
label := url.PathEscape(issuer + ":" + account)
q := url.Values{"secret": {secret}, "issuer": {issuer}, "algorithm": {"SHA1"},
"digits": {fmt.Sprint(digits)}, "period": {"30"}}
return "otpauth://totp/" + label + "?" + q.Encode()
}
// Code is the password for secret at time t.
func Code(secret string, t time.Time) (string, error) {
key, err := decode(secret)
if err != nil {
return "", err
}
return code(key, uint64(t.Unix())/uint64(step.Seconds())), nil
}
// Check reports whether given is a valid password for secret at time t,
// allowing one step of drift either way.
func Check(secret, given string, t time.Time) bool {
key, err := decode(secret)
if err != nil {
return false
}
given = strings.TrimSpace(given)
if len(given) != digits {
return false
}
ctr := uint64(t.Unix()) / uint64(step.Seconds())
for d := -skew; d <= skew; d++ {
if hmac.Equal([]byte(code(key, ctr+uint64(d))), []byte(given)) {
return true
}
}
return false
}
func decode(secret string) ([]byte, error) {
s := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(secret), " ", ""))
return base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(s)
}
func code(key []byte, counter uint64) string {
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], counter)
mac := hmac.New(sha1.New, key)
mac.Write(buf[:])
sum := mac.Sum(nil)
off := sum[len(sum)-1] & 0x0f
trunc := binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff
mod := uint32(1)
for i := 0; i < digits; i++ {
mod *= 10
}
return fmt.Sprintf("%0*d", digits, trunc%mod)
}
+46
View File
@@ -0,0 +1,46 @@
package totp
import (
"testing"
"time"
)
// RFC 6238 appendix B, SHA1 test vectors for the ASCII key "12345678901234567890".
func TestRFC6238Vectors(t *testing.T) {
const secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" // base32 of the RFC key
for _, tc := range []struct {
unix int64
want string
}{
{59, "287082"},
{1111111109, "081804"},
{1111111111, "050471"},
{1234567890, "005924"},
{2000000000, "279037"},
} {
got, err := Code(secret, time.Unix(tc.unix, 0))
if err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Errorf("Code at %d = %s, want %s", tc.unix, got, tc.want)
}
}
}
func TestCheckAcceptsDriftAndRejectsJunk(t *testing.T) {
s := NewSecret()
now := time.Now()
c, _ := Code(s, now)
if !Check(s, c, now.Add(29*time.Second)) {
t.Fatal("code within drift window rejected")
}
if Check(s, c, now.Add(5*time.Minute)) {
t.Fatal("stale code accepted")
}
for _, bad := range []string{"", "12345", "abcdef", "1234567"} {
if Check(s, bad, now) {
t.Fatalf("junk code %q accepted", bad)
}
}
}
+18
View File
@@ -0,0 +1,18 @@
# Single source of truth for all platform versions (semver).
# Bump with: make bump PLATFORM=<server|linux|macos|windows|bsd|android> PART=<major|minor|patch>
VERSION_SERVER := 0.1.0
VERSION_LINUX := 0.1.0
VERSION_MACOS := 0.1.0
VERSION_WINDOWS := 0.1.0
VERSION_BSD := 0.1.0
VERSION_ANDROID := 0.1.0
# Monotonic Android versionCode, never decreases.
ANDROID_VERSION_CODE := 1
# Reverse-DNS application identifiers.
APPID_ANDROID := cc.tiredsysadmin.android.keeporg
APPID_MACOS := cc.tiredsysadmin.macos.keeporg
APPID_LINUX := cc.tiredsysadmin.linux.keeporg
APPID_WINDOWS := cc.tiredsysadmin.windows.keeporg
APPID_BSD := cc.tiredsysadmin.bsd.keeporg
APPID_SERVER := cc.tiredsysadmin.linux.keeporgd