Files
keeporg/cmd/keeporgd/main.go
T
root 7eed722ff5 Fix single sign-on from the web, and make passwords changeable
Why: two questions about operating this turned up two gaps. "Where do I get
the admin password" had no answer once the line scrolled out of the log,
and "how do I set up OIDC" ran straight into a sign-in link that could not
work.

What:
- /oidc/login with no parameters now signs a browser in and ends in a
  session cookie. It previously required the user code of a waiting client,
  so the link on the web sign-in page always failed. With a code it still
  approves a device, and the two paths no longer overlap: an empty user
  code matches no pending device.
- POST /api/v1/auth/password, `keeporg passwd`, and a form on /web/vaults.
  Changing a password requires the current one, and the one-time code when
  a second factor is enrolled, and revokes that account's other sessions.
  Until now nothing could change a password, which made the advice printed
  at first start impossible to follow.
- keeporgd -reset-admin, for when that first password was not kept: a new
  one printed once, any second factor cleared, sessions revoked. It writes
  the state document directly and says to stop the server first.
- The client read stdin through a new buffered reader per prompt, so the
  first prompt swallowed the rest and anything asking twice could not be
  scripted. One shared reader now. A prompt for a password being set no
  longer reads $KEEPORG_PASSWORD either; that variable holds the password
  you sign in with, and taking it here would have set the new password to
  the old one.
- HANDOVER gains a first-sign-in section and an OIDC setup section covering
  the redirect URI, the config block, both entry points and how an identity
  becomes an account.

Proved: OIDC now has tests, against a stub provider that serves discovery,
authorization, token and userinfo. They cover the web sign-in that was
broken, device approval, linking an existing account by username, the
refusal when auto_create is off, and a forged callback state. Eight more
cover the password change. Checked by hand with the real binaries: the
first-start password logs once and works, -reset-admin replaces it while
the old one stops working, and `keeporg passwd` changes it from a pipe.
Server 0.5.0, desktop clients 0.3.0.
2026-10-30 10:47:51 +01:00

365 lines
10 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")
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()
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)
}
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
}
// resetAdministrator gives the server's administrator a new password, for
// when the one printed at first start was not kept. It writes the state
// document directly, so the server must be stopped: a running one holds the
// state in memory and would write over this on its next change.
//
// 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()
path := filepath.Join(cfg.DataDir, "state.json")
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("no server state at %s: %w", path, err)
}
st, err := store.Open(path)
if err != nil {
return err
}
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)
}
lg.Print("if keeporgd was running while this ran, restart it now so it reloads the change")
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)
}