Why: the server is a single static binary with no dependencies, so its container should be nothing but that binary. Shipping one also makes the master key easy to lose, which needed handling rather than documenting. What: - Dockerfile: two stages, runtime is scratch plus the binary and a CA bundle for reaching an OIDC provider, running as uid 65532. About 9 MB. The build stage copies only go.mod, cmd/keeporgd and internal, so client or docs changes do not invalidate it. - The image has no shell, so HEALTHCHECK runs `keeporgd -healthcheck`, a new flag that probes /healthz on the configured address and exits non-zero with a reason. podman needs --format docker to keep the healthcheck at all, which the Makefile passes when it picks podman. - New -master-key-file flag, so a mounted secret works without a config file. - The server now checks at startup whether the master key still opens the stored vault keys, and says so. Without it, a container restarted without its key discovers the problem one failed merge at a time. - make docker, docker-run and docker-save; a compose example with read_only, cap_drop ALL and no-new-privileges. - The client and the server now version independently, so their Debian changelogs are split per package and `make changelog` takes PKG=. Server 0.3.0; the clients stay at 0.2.0. Proved: built with podman and run. The container serves /healthz and /web, its own healthcheck reports healthy, and a vault created through it was edited from the same base by keepassxc-cli twice, merged by the server into version 3, and read back with both entries intact. Restarted without the master key it warns "1 of 1 stored vault keys do not open with this master key" and answers a merging push with 409 merge_failed, leaving the vault untouched; restored, it logs "1 vault keys available for merging" and merges again. The same key supplied through -master-key-file from a mounted file works identically. Both .deb packages build with their own changelog, and make check is clean.
282 lines
8.0 KiB
Go
282 lines
8.0 KiB
Go
// 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"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"syscall"
|
|
"time"
|
|
|
|
"cc.tiredsysadmin.keeporg/internal/kdbx"
|
|
"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"`
|
|
MasterKeyFile string `json:"master_key_file"`
|
|
VaultKDF vaultKDF `json:"vault_kdf"`
|
|
OIDC server.OIDCConfig `json:"oidc"`
|
|
}
|
|
|
|
// vaultKDF is the work factor for vaults this server creates, in the plain
|
|
// units an operator wants to edit.
|
|
type vaultKDF struct {
|
|
MemoryMB uint32 `json:"memory_mb"`
|
|
Iterations uint32 `json:"iterations"`
|
|
Lanes uint8 `json:"lanes"`
|
|
}
|
|
|
|
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,
|
|
MasterKeyFile: f.MasterKeyFile,
|
|
}
|
|
if f.VaultKDF.MemoryMB > 0 {
|
|
c.VaultKDF = kdbx.KDFParams{
|
|
MemoryKiB: f.VaultKDF.MemoryMB * 1024,
|
|
Iterations: f.VaultKDF.Iterations,
|
|
Lanes: f.VaultKDF.Lanes,
|
|
}
|
|
}
|
|
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)")
|
|
masterKey = flag.String("master-key-file", "",
|
|
"file holding the base64 master key that wraps the vault keys; "+
|
|
"$"+secret.MasterKeyEnv+" takes precedence over it")
|
|
showVer = flag.Bool("version", false, "print the version and exit")
|
|
healthOnly = flag.Bool("healthcheck", false,
|
|
"probe a running server on this address and exit 0 if it is healthy")
|
|
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
|
|
}
|
|
if *masterKey != "" {
|
|
cfg.MasterKeyFile = *masterKey
|
|
}
|
|
|
|
if *healthOnly {
|
|
// The container image has no shell, so the binary probes itself.
|
|
if err := healthcheck(cfg); err != nil {
|
|
fmt.Fprintln(os.Stderr, "unhealthy:", err)
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// healthcheck asks a server listening on this configuration whether it is
|
|
// serving, and says why not when it is not.
|
|
func healthcheck(cfg server.Config) error {
|
|
cfg.Defaults()
|
|
host, port, err := net.SplitHostPort(cfg.Addr)
|
|
if err != nil {
|
|
return fmt.Errorf("address %q: %w", cfg.Addr, err)
|
|
}
|
|
if host == "" || host == "0.0.0.0" || host == "::" {
|
|
host = "127.0.0.1"
|
|
}
|
|
scheme := "http"
|
|
client := &http.Client{Timeout: 5 * time.Second}
|
|
if cfg.TLSCert != "" && cfg.TLSKey != "" {
|
|
scheme = "https"
|
|
// A loopback probe of our own listener: the certificate's name is
|
|
// the public one, which this connection is not using.
|
|
client.Transport = &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
|
|
}
|
|
}
|
|
resp, err := client.Get(scheme + "://" + net.JoinHostPort(host, port) + "/healthz")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("/healthz returned %s", resp.Status)
|
|
}
|
|
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,
|
|
MasterKeyFile: "/etc/keeporg/master.key",
|
|
VaultKDF: vaultKDF{MemoryMB: 32, Iterations: 2, Lanes: 2},
|
|
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)
|
|
}
|