diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e64bec4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +# The image is built from go.mod, cmd/keeporgd and internal only. Everything +# else just makes the build context bigger. +* +!go.mod +!cmd/keeporgd/ +!internal/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ce25fa0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,81 @@ +# syntax=docker/dockerfile:1 + +# KeepOrg synchronization server. +# +# The server is a static binary with no third-party dependencies and no cgo, +# so the runtime image carries the binary, a certificate bundle for talking +# to an OIDC provider, and nothing else. There is no shell in it: the binary +# probes its own health. +# +# podman build --format docker -t keeporgd:0.3.0 --build-arg VERSION=0.3.0 . +# docker build -t keeporgd:0.3.0 --build-arg VERSION=0.3.0 . +# +# podman needs --format docker, because the OCI image format has no +# HEALTHCHECK field and drops it silently. +# +# or `make docker`, which takes the version from versions.mk. + +FROM golang:1.24-alpine AS build + +ARG VERSION=dev +WORKDIR /src + +# Only what the server is built from, so a change to the clients, the +# Android app or the docs does not invalidate this layer. +COPY go.mod ./ +COPY cmd/keeporgd ./cmd/keeporgd +COPY internal ./internal + +RUN CGO_ENABLED=0 GOOS=linux go build \ + -trimpath \ + -ldflags "-s -w -X main.Version=${VERSION}" \ + -o /out/keeporgd ./cmd/keeporgd + +# Empty directories to copy into the scratch image with the right owner. +# There is no shell there to create them later. +RUN mkdir -p /out/var/lib/keeporg /out/etc/keeporg + + +FROM scratch + +ARG VERSION=dev +LABEL org.opencontainers.image.title="KeepOrg server" \ + org.opencontainers.image.description="Synchronization server for collaborative KeePass databases" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.source="https://garage.rcmd.space/keeporg" \ + org.opencontainers.image.licenses="AGPL-3.0-or-later" \ + cc.tiredsysadmin.application="cc.tiredsysadmin.linux.keeporgd" + +# Needed to reach an OIDC provider over TLS. Nothing else from the builder +# comes along. +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt + +# The data directory holds state.json and the KDBX blobs. The configuration +# directory holds server.json and, unless the key is supplied through +# KEEPORG_MASTER_KEY, the master key that unwraps the vault keys. They are +# separate on purpose: whoever holds both can read every merge vault. +# +# Give the master key somewhere durable, one of these two ways: +# +# -e KEEPORG_MASTER_KEY="$(head -c 32 /dev/urandom | base64)" (preferred) +# -v keeporg-config:/etc/keeporg +# +# With neither, the server mints a key into the container's own layer on +# first start and loses it on the next `run`. It says so loudly at startup, +# and merging is all that stops working: clients still open their vaults. +COPY --from=build --chown=65532:65532 /out/var/lib/keeporg /var/lib/keeporg +COPY --from=build --chown=65532:65532 /out/etc/keeporg /etc/keeporg +VOLUME ["/var/lib/keeporg", "/etc/keeporg"] + +COPY --from=build /out/keeporgd /keeporgd + +# A fixed unprivileged id, matching the convention used by distroless. +USER 65532:65532 +EXPOSE 8765 + +# The image has no shell, so the binary checks itself. +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD ["/keeporgd", "-healthcheck", "-addr", ":8765"] + +ENTRYPOINT ["/keeporgd"] +CMD ["-addr", ":8765", "-data", "/var/lib/keeporg"] diff --git a/Makefile b/Makefile index da21d9a..1af496a 100644 --- a/Makefile +++ b/Makefile @@ -102,11 +102,11 @@ deb-client: $(DEB_CLIENT) ## build the client .deb deb-server: $(DEB_SERVER) ## build the server .deb $(DEB_CLIENT): $(BUILD)/linux-$(DEB_ARCH)/keeporg packaging/deb/client/control.in \ - packaging/deb/changelog packaging/systemd/keeporg.service + packaging/deb/client/changelog packaging/systemd/keeporg.service scripts/build-deb.sh client $(VERSION_LINUX) $(DEB_ARCH) $@ $(DEB_SERVER): $(BUILD)/linux-$(DEB_ARCH)/keeporgd packaging/deb/server/control.in \ - packaging/deb/changelog packaging/systemd/keeporgd.service + packaging/deb/server/changelog packaging/systemd/keeporgd.service scripts/build-deb.sh server $(VERSION_SERVER) $(DEB_ARCH) $@ DMG_ARCH ?= $(HOSTARCH) @@ -158,6 +158,39 @@ $(APK): $(shell find android/app/src -type f 2>/dev/null) android/app/build.grad cp $(APK_BUILT) $@ @printf 'built %s\n' "$@" +# ------------------------------------------------------------------- container + +# Either engine will do; podman needs no daemon and no root. +CONTAINER ?= $(shell command -v podman 2>/dev/null || command -v docker 2>/dev/null || echo docker) +IMAGE ?= keeporgd +IMAGE_TAG ?= $(VERSION_SERVER) +# podman defaults to the OCI image format, which has no HEALTHCHECK field. +IMAGE_FORMAT := $(if $(findstring podman,$(CONTAINER)),--format docker,) +IMAGE_TAR := $(DIST)/keeporgd-$(VERSION_SERVER)-oci.tar + +.PHONY: docker +docker: ## build the server container image + $(CONTAINER) build $(IMAGE_FORMAT) --build-arg VERSION=$(VERSION_SERVER) \ + -t $(IMAGE):$(IMAGE_TAG) -t $(IMAGE):latest . + @printf 'built %s:%s\n' "$(IMAGE)" "$(IMAGE_TAG)" + +.PHONY: docker-run +docker-run: docker ## run the image on 127.0.0.1:8765 with throwaway state + $(CONTAINER) run --rm -it \ + -p 127.0.0.1:8765:8765 \ + -e KEEPORG_MASTER_KEY="$$(head -c 32 /dev/urandom | base64)" \ + -e KEEPORG_ADMIN_PASSWORD="$${KEEPORG_ADMIN_PASSWORD:-dev-password-1}" \ + $(IMAGE):$(IMAGE_TAG) -addr :8765 -data /var/lib/keeporg -url http://127.0.0.1:8765 + +.PHONY: docker-save +docker-save: $(IMAGE_TAR) ## save the image as a tarball under dist/ + +$(IMAGE_TAR): docker + @mkdir -p $(DIST) + $(CONTAINER) save --format oci-archive -o $@ $(IMAGE):$(IMAGE_TAG) 2>/dev/null || \ + $(CONTAINER) save -o $@ $(IMAGE):$(IMAGE_TAG) + @printf 'saved %s\n' "$@" + # --------------------------------------------------------------------- publish FDROID_HOST ?= rcmd@garage.rcmd.space @@ -183,8 +216,9 @@ bump: ## bump a version: make bump PLATFORM=android PART=minor scripts/bump.sh "$(PLATFORM)" "$(PART)" .PHONY: changelog -changelog: ## add a Debian changelog entry: make changelog MSG="what changed" - scripts/changelog.sh "$(VERSION_LINUX)" "$(MSG)" +changelog: ## add a changelog entry: make changelog PKG=server MSG="what changed" + scripts/changelog.sh "$(PKG)" \ + "$(if $(filter server,$(PKG)),$(VERSION_SERVER),$(VERSION_LINUX))" "$(MSG)" # ------------------------------------------------------------------ local runs diff --git a/README.md b/README.md index 89b6817..ced0f26 100644 --- a/README.md +++ b/README.md @@ -54,12 +54,26 @@ Or open `http://127.0.0.1:8765/web` and create the vault from the browser. ```sh make deb # Debian packages for the client and the server +make docker # server container image, scratch-based, about 9 MB make dmg # macOS disk image with an /Applications symlink (on macOS) make windows # Windows zip with a Task Scheduler job make android # unsigned release APK (needs the Android SDK) make help # every target ``` +Running the server in a container: + +```bash +podman run -d -p 8765:8765 \ + -e KEEPORG_MASTER_KEY="$(head -c 32 /dev/urandom | base64)" \ + -e KEEPORG_ADMIN_PASSWORD=change-me \ + -v keeporg-data:/var/lib/keeporg \ + keeporgd:latest -addr :8765 -data /var/lib/keeporg -url https://keeporg.example.com +``` + +Keep that master key: it unwraps the vault keys, and without it the server +can no longer merge. See [packaging/docker/compose.yaml](packaging/docker/compose.yaml). + ## Documentation - [docs/HANDOVER.md](docs/HANDOVER.md) — architecture, the decisions behind diff --git a/cmd/keeporgd/main.go b/cmd/keeporgd/main.go index 140e5f8..4675324 100644 --- a/cmd/keeporgd/main.go +++ b/cmd/keeporgd/main.go @@ -6,11 +6,13 @@ package main import ( "context" + "crypto/tls" "encoding/json" "errors" "flag" "fmt" "log" + "net" "net/http" "os" "os/signal" @@ -81,8 +83,13 @@ func main() { 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") - writeCfg = flag.String("write-config", "", "write a commented example config to this path 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() @@ -118,6 +125,18 @@ func main() { 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 { @@ -205,6 +224,38 @@ func bootstrap(srv *server.Server, lg *log.Logger) error { 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", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index c2a0f9f..49c6b61 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -34,7 +34,9 @@ internal/client/ shared client engine: API wrapper, merge policy, mirror internal/secret/ password verifiers, tokens, identifiers internal/totp/ RFC 6238 second factor android/ Android client: DocumentsProvider, sync service, UI -packaging/ deb, macOS bundle and DMG, Windows zip, systemd units +Dockerfile the server's container image, built from scratch +packaging/ deb, macOS bundle and DMG, Windows zip, systemd units, + container compose example scripts/ package builders, version bump, changelog versions.mk the one place every platform's version lives Makefile the only entry point for building and publishing @@ -404,6 +406,43 @@ keeporg db -id -strategy merge keeporg db -id -forget-password # the server can no longer read it ``` +### Running it in a container + +```sh +make docker # keeporgd:, about 9 MB +make docker-save # dist/keeporgd--oci.tar +``` + +The image is `scratch` plus the static binary and a CA bundle, running as +uid 65532. There is no shell in it, so `HEALTHCHECK` runs the binary with +`-healthcheck`, which probes `/healthz` on the configured address. +`make docker` uses podman when it is installed and docker otherwise; podman +needs `--format docker` for the image to keep its healthcheck, which the +Makefile passes. + +Two directories matter, and the whole point is that they are separate: + +- `/var/lib/keeporg` — state and blobs. Mount a volume. +- `/etc/keeporg` — `server.json` and, by default, the master key. + +**Give the master key somewhere durable**, either `KEEPORG_MASTER_KEY` from +your secret store, or `-master-key-file` pointing at a mounted secret, or a +volume on `/etc/keeporg`. A container started with none of those mints a key +into its own layer and loses it on the next `run`. That is survivable but +annoying: the vaults still open for their users, and only merging stops. The +server says so at startup — + +``` +WARNING: 1 of 1 stored vault keys do not open with this master key. +``` + +— and a merging push then answers `409 merge_failed` rather than corrupting +anything. Restore the original key and it resumes. + +`packaging/docker/compose.yaml` is a worked example with `read_only`, +`cap_drop: ALL` and `no-new-privileges`; the server writes one directory and +needs no capabilities. + ## Versioning and publishing `versions.mk` is the only place a version lives, one per platform plus the diff --git a/internal/server/server.go b/internal/server/server.go index 280df4e..97b8718 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -99,10 +99,45 @@ func New(cfg Config, lg *log.Logger) (*Server, error) { if cfg.OIDC.Enabled() { s.oidc = newOIDCProvider(cfg.OIDC, cfg.ExternalURL+"/oidc/callback") } + s.checkVaultKeys() s.routes() return s, nil } +// checkVaultKeys reports at startup whether the master key still opens the +// vault keys in the state document. Getting this wrong is quiet and +// expensive: a container started without its master key mounted mints a new +// one, and every merge then fails one push at a time. Saying so once, at +// startup, is the difference between a puzzle and a fixable mistake. +func (s *Server) checkVaultKeys() { + var total, broken int + s.st.Read(func(st *store.State) { + for id, d := range st.Databases { + if d.WrappedKey == "" { + continue + } + total++ + if s.keyring == nil { + broken++ + continue + } + if _, err := s.keyring.Unwrap(id, d.WrappedKey); err != nil { + broken++ + } + } + }) + switch { + case broken == 0 && total > 0: + s.log.Printf("%d vault keys available for merging", total) + case broken > 0: + s.log.Printf("WARNING: %d of %d stored vault keys do not open with this "+ + "master key. Those vaults cannot be merged until the original key is "+ + "restored (%s or %s). Nothing is lost: clients still open the vaults "+ + "with their own passwords.", + broken, total, secret.MasterKeyEnv, s.cfg.MasterKeyFile) + } +} + // Store exposes the metadata store for bootstrap commands. func (s *Server) Store() *store.Store { return s.st } diff --git a/packaging/deb/changelog b/packaging/deb/client/changelog similarity index 100% rename from packaging/deb/changelog rename to packaging/deb/client/changelog diff --git a/packaging/deb/server/changelog b/packaging/deb/server/changelog new file mode 100644 index 0000000..8129cbb --- /dev/null +++ b/packaging/deb/server/changelog @@ -0,0 +1,40 @@ +keeporgd (0.3.0) unstable; urgency=medium + + * Container image: a scratch-based image of about 9 MB holding the static + binary and a CA bundle, running as an unprivileged user. It has no + shell, so HEALTHCHECK runs the binary's own -healthcheck probe. + * New -master-key-file flag, so a mounted secret can be used without a + configuration file. + * At startup the server now reports whether the master key still opens the + stored vault keys. A container restarted without its key would otherwise + discover this one failed merge at a time. + + -- tiredsysadmin Fri, 30 Oct 2026 09:15:50 +0100 + +keeporgd (0.2.0) unstable; urgency=medium + + * Server-side merging. A vault the server can open has its two concurrent + saves folded together in memory, by KeePass's own synchronization rules, + instead of one being refused. The losing version of an edited entry is + kept in that entry's history. + * New vault creation: a web interface at /web, and `keeporg vault -create`, + both of which build the KDBX file and set its password. + * Vault keys are stored encrypted under a master key in + /etc/keeporg/master.key, created on first start. Back it up separately + from /var/lib/keeporg. + * The KDBX 4 codec, its merge engine, and the ChaCha20, BLAKE2b and Argon2 + it needs are part of the package; they carry no new dependencies. + + -- tiredsysadmin Thu, 29 Oct 2026 17:28:45 +0100 + +keeporgd (0.1.0) unstable; urgency=medium + + * First release. + * keeporgd: synchronization server with nested group access control, + invite-link and OIDC registration, built-in TOTP second factor, + versioned KDBX blobs and server-sent update notifications. + * keeporg: desktop client that mirrors assigned databases as ordinary + .kdbx files, pushes every save and applies remote changes, with + server-wins, client-wins and manual merge strategies. + + -- tiredsysadmin Thu, 29 Oct 2026 12:00:00 +0000 diff --git a/packaging/docker/compose.yaml b/packaging/docker/compose.yaml new file mode 100644 index 0000000..2fa2074 --- /dev/null +++ b/packaging/docker/compose.yaml @@ -0,0 +1,51 @@ +# A deployment of the KeepOrg server, showing the two things that are easy +# to get wrong: where the master key comes from, and that it does not live +# in the data volume. +# +# podman-compose up -d (or docker compose up -d) +# +# Generate the master key once, and keep a copy somewhere safe: +# head -c 32 /dev/urandom | base64 > master.key +# +# Without it the server still runs and still synchronizes; it only refuses +# to create or merge vaults. + +services: + keeporgd: + # Override with KEEPORG_VERSION; `make docker` tags the version from + # versions.mk and :latest. + image: keeporgd:${KEEPORG_VERSION:-0.3.0} + build: + context: ../.. + args: + VERSION: "${KEEPORG_VERSION:-0.3.0}" + restart: unless-stopped + # Terminate TLS in front of this, and set external_url to the address a + # browser sees: invite links, device approval and the OIDC redirect are + # built from it. + command: + - "-addr=:8765" + - "-data=/var/lib/keeporg" + - "-url=https://keeporg.example.com" + ports: + - "127.0.0.1:8765:8765" + environment: + # Preferred: the key never touches a disk the container can write. + # Read it from your secret store and pass it in. + KEEPORG_MASTER_KEY: "${KEEPORG_MASTER_KEY:?set KEEPORG_MASTER_KEY, see the comment above}" + # Only read on the very first start, when no accounts exist yet. + KEEPORG_ADMIN_PASSWORD: "${KEEPORG_ADMIN_PASSWORD:-}" + volumes: + - keeporg-data:/var/lib/keeporg + # Only needed instead of KEEPORG_MASTER_KEY above, or to mount a + # server.json. Keep it on different storage from keeporg-data. + # - keeporg-config:/etc/keeporg + # The server writes one directory and needs no privileges at all. + read_only: true + cap_drop: ["ALL"] + security_opt: ["no-new-privileges:true"] + user: "65532:65532" + +volumes: + keeporg-data: + # keeporg-config: diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh index d489ccf..2d19f95 100755 --- a/scripts/build-deb.sh +++ b/scripts/build-deb.sh @@ -41,7 +41,7 @@ mkdir -p "$stage/DEBIAN" "$stage/usr/share/doc/$pkg" sed -e "s/@VERSION@/$version/" -e "s/@ARCH@/$arch/" \ "$root/packaging/deb/$kind/control.in" > "$stage/DEBIAN/control" install -m 0644 "$root/packaging/deb/copyright" "$stage/usr/share/doc/$pkg/copyright" -gzip -9nc "$root/packaging/deb/changelog" > "$stage/usr/share/doc/$pkg/changelog.Debian.gz" +gzip -9nc "$root/packaging/deb/$kind/changelog" > "$stage/usr/share/doc/$pkg/changelog.Debian.gz" chmod 0644 "$stage/usr/share/doc/$pkg/changelog.Debian.gz" if [ "$kind" = server ]; then diff --git a/scripts/changelog.sh b/scripts/changelog.sh index b001e69..73cdb2d 100755 --- a/scripts/changelog.sh +++ b/scripts/changelog.sh @@ -1,15 +1,26 @@ #!/bin/sh -# Prepend an entry to the Debian changelog. -# changelog.sh "what changed" +# Prepend an entry to one package's Debian changelog. The client and the +# server version independently, so they keep separate changelogs. +# changelog.sh "what changed" set -eu -version=${1:-} -message=${2:-} +package=${1:-} +version=${2:-} +message=${3:-} root=$(cd "$(dirname "$0")/.." && pwd) -file=$root/packaging/deb/changelog + +case $package in +client) name=keeporg ;; +server) name=keeporgd ;; +*) + echo 'usage: make changelog PKG= MSG="what changed"' >&2 + exit 2 + ;; +esac +file=$root/packaging/deb/$package/changelog if [ -z "$version" ] || [ -z "$message" ]; then - echo 'usage: make changelog MSG="what changed"' >&2 + echo 'usage: make changelog PKG= MSG="what changed"' >&2 exit 2 fi @@ -17,7 +28,7 @@ author=${DEBFULLNAME:-tiredsysadmin} email=${DEBEMAIL:-rcmd@garage.rcmd.space} tmp=$(mktemp) { - printf 'keeporg (%s) unstable; urgency=medium\n\n' "$version" + printf '%s (%s) unstable; urgency=medium\n\n' "$name" "$version" printf '%s\n' "$message" | fold -s -w 72 | sed -e '1s/^/ * /' -e '2,$s/^/ /' printf '\n' printf ' -- %s <%s> %s\n\n' "$author" "$email" "$(date -R)" diff --git a/versions.mk b/versions.mk index 8f74149..b294924 100644 --- a/versions.mk +++ b/versions.mk @@ -1,6 +1,6 @@ # Single source of truth for all platform versions (semver). # Bump with: make bump PLATFORM= PART= -VERSION_SERVER := 0.2.0 +VERSION_SERVER := 0.3.0 VERSION_LINUX := 0.2.0 VERSION_MACOS := 0.2.0 VERSION_WINDOWS := 0.2.0