Files
keeporg/cmd/keeporgd/main.go
T
root 9c459909c3 Take the master key from KEEPORG_MASTER_KEY_FILE
Why: the usual way to hand a secret to a container is to mount it as a file
and name the path in the environment. Compose, swarm and Kubernetes all do
it that way, and it keeps the key out of the process environment and off any
disk the container can write.

What: the master key is now looked for in KEEPORG_MASTER_KEY, then
KEEPORG_MASTER_KEY_FILE, then the configured master_key_file. A secret file
may hold base64, with or without padding or a trailing newline, or exactly
32 raw bytes, because secret stores produce both.

Only the configured path is ever created, which is what keeps a plain
package install working with no setup. The two environment variables name a
key something else owns, so a missing one is reported and vault creation
refused. Minting a replacement there would be worse than failing: it opens
none of the existing vaults, and the mistake would otherwise surface one
failed merge at a time.

The compose example now provisions the key as a real secret rather than an
environment variable, and the Dockerfile and README show the same. Neither
names a version any more, so they stop drifting as the server is bumped.

Proved: unit tests for every source and precedence, all four accepted
encodings, and that a missing secret writes nothing anywhere. In real
containers: a podman secret carrying base64 survives a stop and start with
"1 vault keys available for merging" and merges a stale push afterwards; a
secret holding 32 raw bytes creates a vault; and a secret that failed to
mount is named in the startup log, with vault creation answering with the
three places a key may come from.
2026-10-30 09:43:42 +01:00

283 lines
8.1 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 master key that wraps the vault keys, created if "+
"absent; $"+secret.MasterKeyEnv+" and $"+secret.MasterKeyFileEnv+
" take precedence over it and are never created")
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)
}