Add KeepOrg sync server: accounts, nested groups, versioned KDBX sync
Why: collaborative KeePass use needs one arbiter of "who wrote last" that never sees a master password, plus access control that follows an org chart. What: stdlib-only Go server. Metadata is a single JSON document with one writer and atomic commit; KDBX payloads are versioned blobs on disk. Access comes from a group tree where a grant on a group reaches every member below it. Login is password+TOTP, invite links, or OIDC through a browser-approved device flow. Pushes carry If-Match and are arbitrated by a per-database merge strategy (server-wins by default). Updates are pushed to clients over SSE. Proved: go test ./internal/... covers RFC 6238 vectors, ACL inheritance and isolation, store rollback, blob pruning, every push verdict, and an end-to-end HTTP pass over login, conflict, force, read-only grants, invite redemption, TOTP enrolment, device login and the event stream.
This commit is contained in:
@@ -0,0 +1,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)
|
||||
}
|
||||
Reference in New Issue
Block a user