// 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" "strings" "syscall" "time" "cc.tiredsysadmin.keeporg/internal/kdbx" "cc.tiredsysadmin.keeporg/internal/secret" "cc.tiredsysadmin.keeporg/internal/server" "cc.tiredsysadmin.keeporg/internal/store" ) // Settings a deployment usually knows only at deploy time can come from the // environment as well. Each sits between the configuration file and its flag. const ( // externalURLEnv sets external_url, overridden by -url. externalURLEnv = "KEEPORG_EXTERNAL_URL" // databaseEnv sets database, overridden by -database. A PostgreSQL URL // carries a password, which is why it belongs in a secret, not a file. databaseEnv = "KEEPORG_DATABASE_URL" // proxiesEnv sets trusted_proxies, comma-separated. proxiesEnv = "KEEPORG_TRUSTED_PROXIES" ) // 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"` Database string `json:"database"` TrustedProxies []string `json:"trusted_proxies"` 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, Database: f.Database, MasterKeyFile: f.MasterKeyFile, TrustedProxies: f.TrustedProxies, } 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 $"+externalURLEnv+" and the config file)") database = flag.String("database", "", "postgres://… or sqlite:; default sqlite:/keeporg.db "+ "(overrides $"+databaseEnv+" and 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") resetAdmin = flag.Bool("reset-admin", false, "reset the built-in administrator's password, print a new one and "+ "exit; stop the server first") ) 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() // The public URL is the one setting a deployment usually knows only at // deploy time, so it can come from the environment as well. A flag // still wins. if v := strings.TrimSpace(os.Getenv(externalURLEnv)); v != "" { cfg.ExternalURL = v } if v := strings.TrimSpace(os.Getenv(databaseEnv)); v != "" { cfg.Database = v } if v := strings.TrimSpace(os.Getenv(proxiesEnv)); v != "" { cfg.TrustedProxies = strings.Split(v, ",") } if *database != "" { cfg.Database = *database } 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 } if *resetAdmin { if err := resetAdministrator(cfg, lg); err != nil { lg.Fatalf("reset-admin: %v", err) } return } srv, err := server.New(cfg, lg) if err != nil { lg.Fatalf("start: %v", err) } defer srv.Close() 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, database %s)", Version, eff.Addr, eff.ExternalURL, srv.DatabaseWhere()) 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 } // resetAdministrator gives the server's administrator a new password, for // when the one printed at first start was not kept. It writes to the // database like any server process would, so a running server picks the // change up within seconds and need not be stopped. // // Any second factor on that account is cleared too. Someone who has lost // the password has often lost the authenticator with it, and a recovery // that leaves you locked out is not one. func resetAdministrator(cfg server.Config, lg *log.Logger) error { cfg.Defaults() kr, err := secret.LoadKeyring(cfg.MasterKeyFile) if err != nil { return fmt.Errorf("master key: %w", err) } st, db, err := server.OpenStore(cfg, kr) if err != nil { return err } defer db.Close() defer st.Close() if st.Empty() { return fmt.Errorf("the database %s holds no server yet; start keeporgd first", db.Where) } password := os.Getenv("KEEPORG_ADMIN_PASSWORD") generated := false if password == "" { password = secret.Token()[:20] generated = true } hash, err := secret.Hash(password) if err != nil { return err } var name string if err := st.Update(func(s *store.State) error { u := s.UserByName("admin") if u == nil { // Whichever account can administer the server will do. for _, candidate := range s.Users { if candidate.ServerAdmin { u = candidate break } } } if u == nil { u = &store.User{ID: secret.ID(), Username: "admin", CreatedAt: time.Now()} s.Users[u.ID] = u } u.PassHash = hash u.TOTPSecret, u.TOTPConfirmed = "", false u.ServerAdmin, u.Disabled = true, false for token, sess := range s.Sessions { if sess.UserID == u.ID { delete(s.Sessions, token) } } name = u.Username return nil }); err != nil { return err } if generated { lg.Printf("reset %q: password %q (shown once). Any second factor on that "+ "account was cleared and its sessions were revoked.", name, password) } else { lg.Printf("reset %q to the password from KEEPORG_ADMIN_PASSWORD. Any second "+ "factor on that account was cleared and its sessions were revoked.", name) } 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, Database: "sqlite:/var/lib/keeporg/keeporg.db", TrustedProxies: []string{"127.0.0.1", "::1"}, 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) }