Verified on Debian 13 arm64 with Cinnamon: the .deb installs, the tray registers and renders its menu, and pull and push sync end to end. - Packages ship 0755 directories whatever the builder's umask, name the native changelog changelog.gz, and override lintian for the static binary and the 0640 server config; prerm uses deb-systemd-invoke. - The tray item is titled KeepOrg for StatusNotifierItem hosts. - Event log lines omit empty fields. - -reset-admin help no longer says to stop the server. Client 0.6.1 (Linux, macOS, Windows, BSD), server 0.12.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
404 lines
12 KiB
Go
404 lines
12 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"
|
|
"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:<path>; default sqlite:<data>/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; a running server picks it up within seconds")
|
|
)
|
|
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)
|
|
}
|