diff --git a/Makefile b/Makefile index 4231eb0..f5f7ea3 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,14 @@ version_for = $(strip \ $(if $(filter windows,$(1)),$(VERSION_WINDOWS),\ $(if $(filter freebsd openbsd netbsd,$(1)),$(VERSION_BSD),0.0.0))))) +# So is the application identifier, which names the macOS login item. +appid_for = $(strip \ + $(if $(filter linux,$(1)),$(APPID_LINUX),\ + $(if $(filter darwin,$(1)),$(APPID_MACOS),\ + $(if $(filter windows,$(1)),$(APPID_WINDOWS),$(APPID_BSD))))) + +client_ldflags = -s -w -X main.Version=$(call version_for,$(1)) -X main.AppID=$(call appid_for,$(1)) + os_of = $(word 1,$(subst -, ,$(1))) arch_of = $(word 2,$(subst -, ,$(1))) @@ -63,11 +71,23 @@ all: server client ## build the server and client for this host server: $(HOST_SERVER) ## build the server for this host client: $(HOST_CLIENT) ## build the desktop client for this host +# The macOS menu bar needs Cocoa, so the client links it with cgo when this +# host is a Mac. Built anywhere else it has every command but `tray`. This +# rule comes before the generic one because make 3.81, which macOS ships, +# takes the first pattern that matches. +DARWIN_CGO := $(if $(filter darwin,$(HOSTOS)),1,0) + +$(BUILD)/darwin-%/keeporg: $(GO_SRC) + @mkdir -p $(dir $@) + GOOS=darwin GOARCH=$* CGO_ENABLED=$(DARWIN_CGO) \ + $(GO) build $(GOFLAGS) -ldflags "$(call client_ldflags,darwin)" \ + -o $@ ./cmd/keeporg + +# Linux and the BSDs talk to the tray over D-Bus, which is pure Go. $(BUILD)/%/keeporg: $(GO_SRC) @mkdir -p $(dir $@) GOOS=$(call os_of,$*) GOARCH=$(call arch_of,$*) CGO_ENABLED=0 \ - $(GO) build $(GOFLAGS) \ - -ldflags "-s -w -X main.Version=$(call version_for,$(call os_of,$*))" \ + $(GO) build $(GOFLAGS) -ldflags "$(call client_ldflags,$(call os_of,$*))" \ -o $@ ./cmd/keeporg $(BUILD)/%/keeporgd: $(GO_SRC) @@ -77,19 +97,39 @@ $(BUILD)/%/keeporgd: $(GO_SRC) -ldflags "-s -w -X main.Version=$(VERSION_SERVER)" \ -o $@ ./cmd/keeporgd +# Windows gets the program twice: keeporg.exe is a console program for the +# commands, keeporg-tray.exe the same code linked as a GUI program, so starting +# the tray opens no console window. $(BUILD)/windows-%/keeporg.exe: $(GO_SRC) @mkdir -p $(dir $@) GOOS=windows GOARCH=$* CGO_ENABLED=0 \ - $(GO) build $(GOFLAGS) -ldflags "-s -w -X main.Version=$(VERSION_WINDOWS)" \ + $(GO) build $(GOFLAGS) -ldflags "$(call client_ldflags,windows)" \ -o $@ ./cmd/keeporg +$(BUILD)/windows-%/keeporg-tray.exe: $(GO_SRC) + @mkdir -p $(dir $@) + GOOS=windows GOARCH=$* CGO_ENABLED=0 \ + $(GO) build $(GOFLAGS) -ldflags "$(call client_ldflags,windows) -H windowsgui" \ + -o $@ ./cmd/keeporg + +# The application icon, drawn by internal/icon, for the Linux icon theme and +# the macOS bundle. +ICONS_HICOLOR := $(BUILD)/icons/hicolor/.stamp +ICONSET := $(BUILD)/icons/KeepOrg.iconset/.stamp + +$(ICONS_HICOLOR): internal/icon/icon.go tools/genicon/main.go + rm -rf $(dir $@) && $(GO) run ./tools/genicon -hicolor $(dir $@) && touch $@ + +$(ICONSET): internal/icon/icon.go tools/genicon/main.go + rm -rf $(dir $@) && $(GO) run ./tools/genicon -iconset $(dir $@) && touch $@ + .PHONY: cross cross: ## build every desktop and server binary cross: $(BUILD)/linux-amd64/keeporg $(BUILD)/linux-arm64/keeporg \ $(BUILD)/linux-amd64/keeporgd $(BUILD)/linux-arm64/keeporgd \ $(BUILD)/darwin-amd64/keeporg $(BUILD)/darwin-arm64/keeporg \ $(BUILD)/freebsd-amd64/keeporg $(BUILD)/freebsd-arm64/keeporg \ - $(BUILD)/windows-amd64/keeporg.exe + $(BUILD)/windows-amd64/keeporg.exe $(BUILD)/windows-amd64/keeporg-tray.exe # ------------------------------------------------------------------ packaging @@ -103,7 +143,8 @@ 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/client/changelog packaging/systemd/keeporg.service + packaging/deb/client/changelog packaging/systemd/keeporg.service \ + packaging/linux/keeporg.desktop $(ICONS_HICOLOR) scripts/build-deb.sh client $(VERSION_LINUX) $(DEB_ARCH) $@ $(DEB_SERVER): $(BUILD)/linux-$(DEB_ARCH)/keeporgd packaging/deb/server/control.in \ @@ -116,8 +157,7 @@ DMG := $(DIST)/KeepOrg-$(VERSION_MACOS)-$(DMG_ARCH).dmg .PHONY: dmg dmg: $(DMG) ## build the macOS DMG with an /Applications symlink (macOS only) -$(DMG): $(BUILD)/darwin-$(DMG_ARCH)/keeporg packaging/macos/Info.plist.in \ - packaging/macos/launcher.sh packaging/macos/launch-agent.plist.in +$(DMG): $(BUILD)/darwin-$(DMG_ARCH)/keeporg packaging/macos/Info.plist.in $(ICONSET) scripts/build-dmg.sh $(VERSION_MACOS) $(DMG_ARCH) $(APPID_MACOS) $@ ZIP := $(DIST)/keeporg-$(VERSION_WINDOWS)-windows-amd64.zip @@ -125,10 +165,11 @@ ZIP := $(DIST)/keeporg-$(VERSION_WINDOWS)-windows-amd64.zip .PHONY: windows windows: $(ZIP) ## build the Windows zip -$(ZIP): $(BUILD)/windows-amd64/keeporg.exe packaging/windows/README.txt +$(ZIP): $(BUILD)/windows-amd64/keeporg.exe $(BUILD)/windows-amd64/keeporg-tray.exe \ + packaging/windows/README.txt packaging/windows/keeporg-task.xml @mkdir -p $(DIST) rm -rf $(BUILD)/windows-stage && mkdir -p $(BUILD)/windows-stage - cp $(BUILD)/windows-amd64/keeporg.exe $(BUILD)/windows-stage/ + cp $(BUILD)/windows-amd64/keeporg.exe $(BUILD)/windows-amd64/keeporg-tray.exe $(BUILD)/windows-stage/ cp packaging/windows/README.txt $(BUILD)/windows-stage/ cp packaging/windows/keeporg-task.xml $(BUILD)/windows-stage/ cd $(BUILD)/windows-stage && zip -q -r ../../$@ . @@ -229,8 +270,8 @@ run-server: $(HOST_SERVER) ## run a server on 127.0.0.1:8765 with data in ./.dev $(HOST_SERVER) -addr 127.0.0.1:8765 -data .devdata -url http://127.0.0.1:8765 .PHONY: run-client -run-client: $(HOST_CLIENT) ## run the sync agent with the local configuration - $(HOST_CLIENT) run +run-client: $(HOST_CLIENT) ## run the tray application with the local configuration + $(HOST_CLIENT) tray nginx: cat config/variables/main.json | tpl -f config/nginx/$(APP_NAME).conf.tmpl > /etc/nginx/sites-available/$(APP_NAME).conf diff --git a/README.md b/README.md index ad7528a..9237cd0 100644 --- a/README.md +++ b/README.md @@ -18,15 +18,18 @@ keep their key off the server entirely, exactly as before. with a tree of user groups, accepts registrations by invite link or OIDC, merges concurrent edits, and pushes updates to connected clients. Its web page at `/web` is for creating a vault and setting its password. -- **Desktop client** (`keeporg`, Linux, macOS, Windows, BSD): mirrors every - assigned database into one directory as a real file, pushes each save and - applies what others push. +- **Desktop client** (`keeporg`, Linux, macOS, Windows, BSD): a system tray + application that mirrors every assigned database into one directory as a + real file, pushes each save and applies what others push. The same binary + is the command line for administration and for headless machines. - **Android client**: the same, published to every file picker as a storage provider. -No third-party dependencies, no cgo, no FUSE. The KDBX 4 codec, including -ChaCha20, BLAKE2b and Argon2, is part of the repository and is tested against -both the relevant RFCs and a real KeePassXC. +No FUSE, and the server has no third-party dependencies and no cgo. The +KDBX 4 codec, including ChaCha20, BLAKE2b and Argon2, is part of the +repository and is tested against both the relevant RFCs and a real +KeePassXC. The desktop client's one dependency is `fyne.io/systray`, for the +tray icon. ## Quick start @@ -41,13 +44,53 @@ KEEPORG_ADMIN_PASSWORD=dev-password-1 make run-server build/linux-arm64/keeporg login -server http://127.0.0.1:8765 -user admin build/linux-arm64/keeporg groups build/linux-arm64/keeporg vault -create "Team Vault" -group -build/linux-arm64/keeporg run +build/linux-arm64/keeporg # the tray; `keeporg run` without a desktop ``` `Team Vault.kdbx` appears in `~/KeepOrg`; open it in KeePassXC with the vault password you just set. Every save is pushed, and a save that races with a colleague's is merged rather than refused. +## The tray + +Started without a command — from the applications menu, the DMG's +`KeepOrg.app`, or `keeporg-tray.exe` on Windows — the client puts a padlock +in the system tray: + +| Item | What it does | +| --- | --- | +| *status line* | signed-in account and whether everything is up to date, or what needs attention | +| **Open Mirror Folder** | the directory holding the `.kdbx` files | +| **Databases ▶** | each database with its state; choosing one opens it in your KeePass application | +| **Sync Now** | reconcile immediately instead of at the next rescan | +| **Sign In… / Sign Out** | sign in by approving a code in the browser (password, second factor or SSO); sign out revokes the session and leaves the files | +| **Open Web Interface** | `/web` on the server, for creating vaults and managing access | +| **Open Config** | the settings file; a saved edit applies at once | +| **Open Log** | the agent's log | +| **Start at Login** | LaunchAgent on macOS, XDG autostart on Linux and BSD, the Run key on Windows | +| **About**, **Quit** | | + +Conflicts, merges and failures raise a desktop notification. + +## Configuration + +`config.json` in the user configuration directory +(`~/.config/keeporg`, `~/Library/Application Support/keeporg`, +`%AppData%\keeporg`) holds every client setting. The first start writes it +in full, and the tray reloads it when it is saved. + +| Key | Default | Meaning | +| --- | --- | --- | +| `server_url` | | the server's base URL, as a browser sees it | +| `username`, `token` | | written by signing in | +| `device_label` | host name | this device in the server's session list | +| `mirror_dir` | `~/KeepOrg` | where the `.kdbx` files are kept; `~` is expanded | +| `poll_seconds` | `2` | how often local saves are looked for | +| `request_timeout_seconds` | `60` | the limit on one API request (the event stream is exempt) | +| `ca_file` | | a PEM bundle trusted besides the system roots, for a private CA | +| `log_file` | | the log; empty means `keeporg.log` beside the config for the tray, standard error for `keeporg run` | +| `notifications` | `true` | desktop notifications from the tray | + Or open `http://127.0.0.1:8765/web` and create the vault from the browser. ## Packaging @@ -56,7 +99,7 @@ Or open `http://127.0.0.1:8765/web` and create the vault from the browser. 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 windows # Windows zip: keeporg-tray.exe, keeporg.exe and a Task Scheduler job make android # unsigned release APK (needs the Android SDK) make help # every target ``` diff --git a/cmd/keeporg/about_other.go b/cmd/keeporg/about_other.go new file mode 100644 index 0000000..e807205 --- /dev/null +++ b/cmd/keeporg/about_other.go @@ -0,0 +1,36 @@ +//go:build !windows + +package main + +import ( + "fmt" + "os/exec" + "runtime" + "strings" +) + +// showAbout shows a native About dialog: AppleScript on macOS, zenity or +// kdialog elsewhere, and the homepage when neither is installed. +func showAbout(configPath string) { + text := aboutText(configPath) + if runtime.GOOS == "darwin" { + go func() { + out, _ := exec.Command("osascript", "-e", fmt.Sprintf( + `button returned of (display dialog %q with title "About KeepOrg" buttons {"Homepage", "OK"} default button "OK" with icon note)`, + text)).Output() + if strings.TrimSpace(string(out)) == "Homepage" { + openPath(homepage) + } + }() + return + } + if p, err := exec.LookPath("zenity"); err == nil { + go exec.Command(p, "--info", "--title=About KeepOrg", "--no-wrap", "--text="+text).Run() + return + } + if p, err := exec.LookPath("kdialog"); err == nil { + go exec.Command(p, "--title", "About KeepOrg", "--msgbox", text).Run() + return + } + openPath(homepage) +} diff --git a/cmd/keeporg/about_windows.go b/cmd/keeporg/about_windows.go new file mode 100644 index 0000000..21d98c7 --- /dev/null +++ b/cmd/keeporg/about_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package main + +import "golang.org/x/sys/windows" + +// showAbout shows a message box. It runs on its own goroutine because the +// box is modal and the tray's event loop must keep running. +func showAbout(configPath string) { + text, _ := windows.UTF16PtrFromString(aboutText(configPath)) + title, _ := windows.UTF16PtrFromString("About KeepOrg") + go windows.MessageBox(0, text, title, windows.MB_OK|windows.MB_ICONINFORMATION) +} diff --git a/cmd/keeporg/agent.go b/cmd/keeporg/agent.go new file mode 100644 index 0000000..261fb10 --- /dev/null +++ b/cmd/keeporg/agent.go @@ -0,0 +1,489 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "os" + "path/filepath" + "sync" + "time" + + "cc.tiredsysadmin.keeporg/internal/client" +) + +// agent is the tray application without the tray: the configuration, the +// mirror's lifecycle and sign-in. The menu only reads view() and calls the +// methods below, so everything that decides anything is testable without a +// desktop session. +type agent struct { + path string + log *log.Logger + sink *logSink + notify func(title, message string) + + mu sync.Mutex + cfg *client.Config + cfgMod time.Time + cfgErr error + mir *client.Mirror + cancel context.CancelFunc + done chan struct{} + // run is why the current mirror stopped, or could not start. It has its + // own lock because the mirror's goroutine sets it, and that goroutine + // must never wait on mu: stopping the mirror holds mu while it waits for + // the goroutine to finish. + run *runErr + pending string // the user code of a browser sign-in in progress + expired bool // the server rejected the stored token + statuses map[string]client.Status +} + +func newAgent(path string, notify func(title, message string)) *agent { + sink := &logSink{} + return &agent{path: path, sink: sink, log: log.New(sink, "", log.LstdFlags), + notify: notify, run: &runErr{}, statuses: map[string]client.Status{}} +} + +type runErr struct { + mu sync.Mutex + err error +} + +func (r *runErr) set(err error) { + r.mu.Lock() + r.err = err + r.mu.Unlock() +} + +func (r *runErr) get() error { + r.mu.Lock() + defer r.mu.Unlock() + return r.err +} + +// start loads the configuration, writing a complete default one on first +// run, and starts synchronizing if this device is signed in. +func (a *agent) start() { + cfg, err := client.EnsureConfig(a.path) + a.mu.Lock() + defer a.mu.Unlock() + a.cfgMod = modTime(a.path) + if cfg == nil { + cfg = client.DefaultConfig() + } + a.cfg, a.cfgErr = cfg, err + a.openLogLocked() + if err != nil { + a.log.Printf("config: %v", err) + return + } + a.startMirrorLocked() +} + +// stop ends synchronization, for quitting. +func (a *agent) stop() { + a.mu.Lock() + defer a.mu.Unlock() + a.stopMirrorLocked() + a.sink.set(nil) +} + +func (a *agent) startMirrorLocked() { + if a.cancel != nil || a.cfg.Token == "" || a.cfg.ServerURL == "" { + return + } + a.run = &runErr{} + api, err := a.cfg.API() + if err != nil { + a.run.set(err) + return + } + mir, err := client.NewMirror(api, a.cfg.MirrorDir, a.log, a.cfg.Poll()) + if err != nil { + a.run.set(err) + return + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + run, notify := a.run, a.cfg.Notifications + a.mir, a.cancel, a.done, a.expired = mir, cancel, done, false + a.log.Printf("mirroring %s into %s", a.cfg.ServerURL, a.cfg.MirrorDir) + go func() { + defer close(done) + err := mir.Run(ctx) + if errors.Is(err, context.Canceled) { + return + } + a.log.Printf("agent stopped: %v", err) + run.set(err) + if notify && errors.Is(err, client.ErrMirrorBusy) { + a.notify("KeepOrg is already running", err.Error()) + } + }() +} + +func (a *agent) stopMirrorLocked() { + if a.cancel == nil { + return + } + a.cancel() + <-a.done + a.mir, a.cancel, a.done = nil, nil, nil + a.statuses = map[string]client.Status{} +} + +// reload rereads the configuration when the file changed, and restarts the +// mirror if a setting it depends on did. A file that no longer parses is +// reported and the running configuration kept. +func (a *agent) reload() { + mod := modTime(a.path) + a.mu.Lock() + defer a.mu.Unlock() + if mod.Equal(a.cfgMod) { + return + } + a.cfgMod = mod + cfg, err := client.LoadConfig(a.path) + if err != nil { + if a.cfgErr == nil || a.cfgErr.Error() != err.Error() { + a.log.Printf("config: %v", err) + a.sayLocked("KeepOrg configuration error", err.Error()) + } + a.cfgErr = err + return + } + old := a.cfg + a.cfg, a.cfgErr = cfg, nil + if old.LogFile != cfg.LogFile { + a.openLogLocked() + } + if mirrorSettingsChanged(old, cfg) || a.cancel == nil { + a.log.Printf("config: reloaded") + a.stopMirrorLocked() + a.startMirrorLocked() + } +} + +// mirrorSettingsChanged reports whether the running mirror was built from +// settings that differ in b. +func mirrorSettingsChanged(a, b *client.Config) bool { + return a.ServerURL != b.ServerURL || a.Token != b.Token || a.MirrorDir != b.MirrorDir || + a.PollSeconds != b.PollSeconds || a.RequestTimeoutSeconds != b.RequestTimeoutSeconds || + a.CAFile != b.CAFile +} + +// tick runs once a second: it notices configuration edits, a revoked +// session, and status changes worth a notification. +func (a *agent) tick() { + a.reload() + + a.mu.Lock() + defer a.mu.Unlock() + if a.mir == nil { + return + } + if _, err := a.mir.LastSync(); client.Code(err) == "unauthorized" { + a.log.Printf("the server no longer accepts this device's session") + a.stopMirrorLocked() + a.cfg.Token, a.expired = "", true + a.saveLocked() + a.sayLocked("KeepOrg signed out", "The session expired or was revoked. Sign in again from the menu.") + return + } + next := map[string]client.Status{} + for _, s := range a.mir.Status() { + next[s.DatabaseID] = s + if title, msg, ok := statusNotice(a.statuses[s.DatabaseID], s); ok { + a.sayLocked(title, msg) + } + } + a.statuses = next +} + +// statusNotice decides whether a database's change of state is worth +// interrupting someone for. Routine pulls and pushes are not. +func statusNotice(prev, cur client.Status) (title, msg string, ok bool) { + if prev.State == cur.State && prev.Message == cur.Message { + return "", "", false + } + switch cur.State { + case "conflict": + return cur.Name + ": conflict", cur.Message, true + case "conflict-resolved": + return cur.Name + ": your copy was kept aside", cur.Message, true + case "merged": + return cur.Name + ": merged", "Your save was merged with a colleague's edit.", true + case "error": + return cur.Name + ": synchronization failed", cur.Message, true + } + return "", "", false +} + +// syncNow asks the mirror for an immediate pass. +func (a *agent) syncNow() { + a.mu.Lock() + defer a.mu.Unlock() + if a.mir != nil { + a.mir.Kick() + } +} + +// signIn runs the browser-approved device flow, which serves passwords, +// second factors and single sign-on alike, so the tray needs no dialogs. +func (a *agent) signIn(ctx context.Context, open func(string)) error { + a.mu.Lock() + if a.pending != "" { + a.mu.Unlock() + return errors.New("a sign-in is already waiting for approval") + } + if a.cfgErr != nil { + a.mu.Unlock() + return a.cfgErr + } + if a.cfg.ServerURL == "" { + a.mu.Unlock() + return errNoServer + } + cfg := *a.cfg + a.mu.Unlock() + + cfg.Token = "" + api, err := cfg.API() + if err != nil { + return err + } + start, err := api.DeviceStart(ctx, cfg.DeviceLabel) + if err != nil { + return err + } + a.setPending(start.UserCode) + defer a.setPending("") + open(start.VerificationURL) + + interval := time.Duration(max(start.Interval, 1)) * time.Second + deadline := time.Now().Add(time.Duration(max(start.ExpiresIn, 60)) * time.Second) + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(interval): + } + sess, err := api.DevicePoll(ctx, start.DeviceCode) + if err != nil { + return err + } + if sess == nil { + continue + } + a.mu.Lock() + a.cfg.Token, a.cfg.Username = sess.Token, sess.User.Username + a.saveLocked() + a.stopMirrorLocked() + a.startMirrorLocked() + a.mu.Unlock() + a.log.Printf("signed in as %s", sess.User.Username) + return nil + } + return errors.New("the approval window expired") +} + +var errNoServer = errors.New("no server is configured: set server_url in the configuration file, save it, and sign in again") + +func (a *agent) setPending(code string) { + a.mu.Lock() + a.pending = code + a.mu.Unlock() +} + +// signOut revokes the session on the server and forgets it here. The files +// stay where they are. +func (a *agent) signOut(ctx context.Context) { + a.mu.Lock() + defer a.mu.Unlock() + a.stopMirrorLocked() + if api, err := a.cfg.API(); err == nil && a.cfg.Token != "" { + if err := api.Logout(ctx); err != nil { + a.log.Printf("revoking the session on the server failed: %v", err) + } + } + a.cfg.Token = "" + a.saveLocked() + a.log.Printf("signed out") +} + +// saveLocked writes the configuration and records the write, so the next +// reload does not mistake it for an edit. +func (a *agent) saveLocked() { + if err := a.cfg.Save(a.path); err != nil { + a.log.Printf("config: %v", err) + } + a.cfgMod = modTime(a.path) +} + +func (a *agent) sayLocked(title, msg string) { + if a.cfg == nil || a.cfg.Notifications { + a.notify(title, msg) + } +} + +// logPath is where the tray logs: the configured file, or keeporg.log beside +// the configuration, since a tray application has no terminal. +func (a *agent) logPath() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.logPathLocked() +} + +func (a *agent) logPathLocked() string { + if a.cfg != nil && a.cfg.LogFile != "" { + return a.cfg.LogFile + } + return filepath.Join(filepath.Dir(a.path), "keeporg.log") +} + +func (a *agent) openLogLocked() { + f, err := openLog(a.logPathLocked()) + if err != nil { + a.sink.set(nil) + fmt.Fprintln(os.Stderr, "keeporg: log:", err) + return + } + a.sink.set(f) +} + +func modTime(path string) time.Time { + fi, err := os.Stat(path) + if err != nil { + return time.Time{} + } + return fi.ModTime() +} + +// logSink is a log destination that can be swapped while loggers hold it. +type logSink struct { + mu sync.Mutex + w io.WriteCloser +} + +func (s *logSink) set(w io.WriteCloser) { + s.mu.Lock() + defer s.mu.Unlock() + if s.w != nil { + s.w.Close() + } + s.w = w +} + +func (s *logSink) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.w == nil { + return os.Stderr.Write(p) + } + return s.w.Write(p) +} + +// view is what the menu shows, decided in one place. +type view struct { + Header string + Tooltip string + SignedIn bool + Pending bool + MirrorDir string + WebURL string + Databases []dbItem +} + +type dbItem struct { + Label string + Path string +} + +// snapshot is the agent's state at one moment, for buildView. +type snapshot struct { + cfg client.Config + cfgErr error + pending string + expired bool + running bool + runErr error + lastSync time.Time + lastErr error + statuses []client.Status +} + +func (a *agent) view() view { + a.mu.Lock() + s := snapshot{cfgErr: a.cfgErr, pending: a.pending, expired: a.expired, + running: a.mir != nil, runErr: a.run.get()} + if a.cfg != nil { + s.cfg = *a.cfg + } + if a.mir != nil { + s.lastSync, s.lastErr = a.mir.LastSync() + s.statuses = a.mir.Status() + } + a.mu.Unlock() + return buildView(s) +} + +func buildView(s snapshot) view { + v := view{SignedIn: s.cfg.Token != "", Pending: s.pending != "", MirrorDir: s.cfg.MirrorDir} + if s.cfg.ServerURL != "" { + v.WebURL = s.cfg.ServerURL + "/web" + } + attention := 0 + for _, st := range s.statuses { + label := st.Name + switch st.State { + case "ok": + label = "✓ " + label + case "merged": + label = "✓ " + label + " (merged)" + case "conflict": + label, attention = "⚠ "+label+": conflict", attention+1 + case "conflict-resolved": + label, attention = "⚠ "+label+": your copy kept aside", attention+1 + case "error": + label, attention = "✗ "+label+": not synchronized", attention+1 + } + if st.Perm == "read" { + label += " (read-only)" + } + item := dbItem{Label: label} + if st.File != "" { + item.Path = filepath.Join(s.cfg.MirrorDir, st.File) + } + v.Databases = append(v.Databases, item) + } + + switch { + case s.cfgErr != nil: + v.Header = "Configuration error, see Open Config" + case s.pending != "": + v.Header = "Approve code " + s.pending + " in the browser" + case s.cfg.ServerURL == "": + v.Header = "No server configured" + case s.expired: + v.Header = "Session expired, sign in again" + case !v.SignedIn: + v.Header = "Signed out" + case errors.Is(s.runErr, client.ErrMirrorBusy): + v.Header = "Another KeepOrg agent is syncing" + case s.runErr != nil: + v.Header = "Stopped: " + s.runErr.Error() + case !s.running || s.lastSync.IsZero(): + v.Header = "Connecting…" + case s.lastErr != nil: + v.Header = "Offline, retrying" + case attention > 0: + v.Header = fmt.Sprintf("%s · %d need attention", s.cfg.Username, attention) + default: + v.Header = s.cfg.Username + " · up to date" + } + v.Tooltip = "KeepOrg " + Version + "\n" + v.Header + return v +} diff --git a/cmd/keeporg/agent_server_test.go b/cmd/keeporg/agent_server_test.go new file mode 100644 index 0000000..c8dee82 --- /dev/null +++ b/cmd/keeporg/agent_server_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "io" + "log" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "cc.tiredsysadmin.keeporg/internal/client" + "cc.tiredsysadmin.keeporg/internal/kdbx" + "cc.tiredsysadmin.keeporg/internal/secret" + "cc.tiredsysadmin.keeporg/internal/server" + "cc.tiredsysadmin.keeporg/internal/store" +) + +// TestAgentAgainstAServer drives the tray's agent against a real server: +// it syncs a database into the mirror, and signs itself out when the server +// revokes its session. +func TestAgentAgainstAServer(t *testing.T) { + srv, err := server.New(server.Config{ + DataDir: t.TempDir(), + MasterKeyFile: filepath.Join(t.TempDir(), "master.key"), + VaultKDF: kdbx.KDFParams{MemoryKiB: 64, Iterations: 1, Lanes: 1}, + }, log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } + hash, err := secret.Hash("admin-password-1") + if err != nil { + t.Fatal(err) + } + if err := srv.Store().Update(func(st *store.State) error { + st.Users["admin"] = &store.User{ID: "admin", Username: "admin", PassHash: hash, ServerAdmin: true} + st.Groups["everyone"] = &store.Group{ID: "everyone", Name: "everyone"} + st.Memberships = append(st.Memberships, + store.Membership{UserID: "admin", GroupID: "everyone", Role: store.RoleAdmin}) + return nil + }); err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(ts.Close) + + ctx := context.Background() + admin := client.NewAPI(ts.URL, "") + sess, err := admin.Login(ctx, "admin", "admin-password-1", "", "admin") + if err != nil { + t.Fatal(err) + } + admin.Token = sess.Token + if _, err := admin.CreateVault(ctx, "Team Vault", "everyone", "merge", "vault-password-1"); err != nil { + t.Fatal(err) + } + device, err := client.NewAPI(ts.URL, "").Login(ctx, "admin", "admin-password-1", "", "tray") + if err != nil { + t.Fatal(err) + } + + cfg := client.DefaultConfig() + cfg.ServerURL, cfg.Token, cfg.Username = ts.URL, device.Token, "admin" + cfg.MirrorDir = t.TempDir() + a, said := newTestAgent(t, cfg) + + waitFor(t, func() bool { a.tick(); return a.view().Header == "admin · up to date" }) + v := a.view() + if len(v.Databases) != 1 || v.Databases[0].Path != filepath.Join(cfg.MirrorDir, "Team Vault.kdbx") { + t.Fatalf("databases %+v", v.Databases) + } + + // Revoke the device's session from the server side. + if err := client.NewAPI(ts.URL, device.Token).Logout(ctx); err != nil { + t.Fatal(err) + } + a.syncNow() + waitFor(t, func() bool { a.tick(); return strings.HasPrefix(a.view().Header, "Session expired") }) + if got, _ := client.LoadConfig(a.path); got.Token != "" { + t.Error("the revoked token was kept in the config") + } + if len(*said) == 0 || !strings.Contains((*said)[len(*said)-1], "signed out") { + t.Errorf("no sign-out notification: %v", *said) + } +} + +func waitFor(t *testing.T, cond func() bool) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for !cond() { + if time.Now().After(deadline) { + t.Fatal("timed out") + } + time.Sleep(50 * time.Millisecond) + } +} diff --git a/cmd/keeporg/agent_test.go b/cmd/keeporg/agent_test.go new file mode 100644 index 0000000..e5c4c7a --- /dev/null +++ b/cmd/keeporg/agent_test.go @@ -0,0 +1,161 @@ +package main + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "cc.tiredsysadmin.keeporg/internal/client" +) + +func TestBuildViewHeader(t *testing.T) { + signed := client.Config{ServerURL: "https://k.example.com", Token: "t", Username: "alice", MirrorDir: "/m"} + now := time.Now() + for _, c := range []struct { + name string + s snapshot + want string + }{ + {"config error", snapshot{cfg: signed, cfgErr: errors.New("x")}, "Configuration error"}, + {"no server", snapshot{}, "No server configured"}, + {"pending", snapshot{cfg: client.Config{ServerURL: "https://k"}, pending: "ABCD"}, "Approve code ABCD"}, + {"signed out", snapshot{cfg: client.Config{ServerURL: "https://k"}}, "Signed out"}, + {"expired", snapshot{cfg: client.Config{ServerURL: "https://k"}, expired: true}, "Session expired"}, + {"busy", snapshot{cfg: signed, runErr: client.ErrMirrorBusy}, "Another KeepOrg agent"}, + {"connecting", snapshot{cfg: signed, running: true}, "Connecting"}, + {"offline", snapshot{cfg: signed, running: true, lastSync: now, lastErr: errors.New("dial")}, "Offline"}, + {"attention", snapshot{cfg: signed, running: true, lastSync: now, + statuses: []client.Status{{Name: "A", State: "conflict"}, {Name: "B", State: "ok"}}}, "alice · 1 need attention"}, + {"fine", snapshot{cfg: signed, running: true, lastSync: now, + statuses: []client.Status{{Name: "A", State: "ok"}}}, "alice · up to date"}, + } { + if got := buildView(c.s).Header; !strings.HasPrefix(got, c.want) { + t.Errorf("%s: header %q, want prefix %q", c.name, got, c.want) + } + } +} + +func TestBuildViewDatabases(t *testing.T) { + v := buildView(snapshot{cfg: client.Config{ServerURL: "https://k.example.com", MirrorDir: "/m"}, + statuses: []client.Status{ + {Name: "Team", File: "Team.kdbx", State: "ok", Perm: "write"}, + {Name: "Ops", File: "Ops.kdbx", State: "conflict-resolved", Perm: "read"}, + {Name: "New", State: "ok"}, + }}) + if v.WebURL != "https://k.example.com/web" { + t.Errorf("web url %q", v.WebURL) + } + want := []dbItem{ + {"✓ Team", filepath.Join("/m", "Team.kdbx")}, + {"⚠ Ops: your copy kept aside (read-only)", filepath.Join("/m", "Ops.kdbx")}, + {"✓ New", ""}, + } + for i, d := range want { + if v.Databases[i] != d { + t.Errorf("database %d = %+v, want %+v", i, v.Databases[i], d) + } + } +} + +func TestStatusNoticeOnlyForNews(t *testing.T) { + ok := client.Status{Name: "A", State: "ok"} + conflict := client.Status{Name: "A", State: "conflict", Message: "server copy written beside it"} + if _, _, n := statusNotice(client.Status{}, ok); n { + t.Error("a routine sync notified") + } + if _, _, n := statusNotice(ok, conflict); !n { + t.Error("a new conflict did not notify") + } + if _, _, n := statusNotice(conflict, conflict); n { + t.Error("the same conflict notified twice") + } +} + +// newTestAgent writes a configuration and starts an agent on it without a +// tray, recording notifications. +func newTestAgent(t *testing.T, cfg *client.Config) (*agent, *[]string) { + t.Helper() + path := filepath.Join(t.TempDir(), "config.json") + if cfg != nil { + if err := cfg.Save(path); err != nil { + t.Fatal(err) + } + } + var said []string + a := newAgent(path, func(title, msg string) { said = append(said, title) }) + a.start() + t.Cleanup(a.stop) + return a, &said +} + +func TestAgentWritesAFullConfigOnFirstStart(t *testing.T) { + a, _ := newTestAgent(t, nil) + if _, err := client.LoadConfig(a.path); err != nil { + t.Fatalf("first start left no usable config: %v", err) + } + if _, err := os.Stat(a.logPath()); err != nil { + t.Errorf("no log file beside the config: %v", err) + } + if got := a.view().Header; got != "No server configured" { + t.Errorf("header %q", got) + } + if err := a.signIn(context.Background(), func(string) {}); !errors.Is(err, errNoServer) { + t.Errorf("sign in without a server: %v", err) + } +} + +func TestAgentReloadsAndReportsABrokenEdit(t *testing.T) { + a, said := newTestAgent(t, nil) + + broken := []byte(`{"server_url": "not a url"}`) + if err := os.WriteFile(a.path, broken, 0o600); err != nil { + t.Fatal(err) + } + future := time.Now().Add(time.Second) + _ = os.Chtimes(a.path, future, future) + a.tick() + if !strings.HasPrefix(a.view().Header, "Configuration error") || len(*said) != 1 { + t.Fatalf("broken edit: header %q, notifications %v", a.view().Header, *said) + } + + fixed := client.DefaultConfig() + fixed.ServerURL = "https://k.example.com" + if err := fixed.Save(a.path); err != nil { + t.Fatal(err) + } + future = future.Add(time.Second) + _ = os.Chtimes(a.path, future, future) + a.tick() + if got := a.view().Header; got != "Signed out" { + t.Fatalf("after fixing the file, header %q", got) + } +} + +func TestAgentHonoursNotificationsSetting(t *testing.T) { + cfg := client.DefaultConfig() + cfg.Notifications = false + a, said := newTestAgent(t, cfg) + a.mu.Lock() + a.sayLocked("title", "message") + a.mu.Unlock() + if len(*said) != 0 { + t.Fatalf("notified with notifications off: %v", *said) + } +} + +func TestMirrorSettingsChanged(t *testing.T) { + a := client.DefaultConfig() + b := *a + b.Notifications, b.LogFile = !a.Notifications, "/tmp/x.log" + if mirrorSettingsChanged(a, &b) { + t.Error("cosmetic settings restarted the mirror") + } + b.MirrorDir = "/elsewhere" + if !mirrorSettingsChanged(a, &b) { + t.Error("a new mirror directory did not restart the mirror") + } +} diff --git a/cmd/keeporg/autostart_darwin.go b/cmd/keeporg/autostart_darwin.go new file mode 100644 index 0000000..d434413 --- /dev/null +++ b/cmd/keeporg/autostart_darwin.go @@ -0,0 +1,58 @@ +//go:build darwin + +package main + +import ( + "fmt" + "os" + "path/filepath" +) + +// launchAgentPath is the per-user LaunchAgent that starts the tray at login. +func launchAgentPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, "Library", "LaunchAgents", AppID+".plist") +} + +func startAtLoginEnabled() bool { + _, err := os.Stat(launchAgentPath()) + return err == nil +} + +func setStartAtLogin(enable bool) error { + path := launchAgentPath() + if !enable { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + exe, err := executable() + if err != nil { + return err + } + plist := fmt.Sprintf(` + + + + Label + %s + ProgramArguments + + %s + tray + + RunAtLoad + + LimitLoadToSessionType + Aqua + ProcessType + Interactive + + +`, AppID, exe) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, []byte(plist), 0o644) +} diff --git a/cmd/keeporg/autostart_other.go b/cmd/keeporg/autostart_other.go new file mode 100644 index 0000000..98cd920 --- /dev/null +++ b/cmd/keeporg/autostart_other.go @@ -0,0 +1,11 @@ +//go:build !(darwin || windows || linux || freebsd || openbsd || netbsd || dragonfly) + +package main + +import "errors" + +func startAtLoginEnabled() bool { return false } + +func setStartAtLogin(bool) error { + return errors.New("starting at login is not supported on this platform") +} diff --git a/cmd/keeporg/autostart_windows.go b/cmd/keeporg/autostart_windows.go new file mode 100644 index 0000000..e65f53a --- /dev/null +++ b/cmd/keeporg/autostart_windows.go @@ -0,0 +1,41 @@ +//go:build windows + +package main + +import ( + "errors" + + "golang.org/x/sys/windows/registry" +) + +// runKey is the per-user list of programs Windows starts at sign-in. +const runKey = `Software\Microsoft\Windows\CurrentVersion\Run` + +func startAtLoginEnabled() bool { + k, err := registry.OpenKey(registry.CURRENT_USER, runKey, registry.QUERY_VALUE) + if err != nil { + return false + } + defer k.Close() + _, _, err = k.GetStringValue("KeepOrg") + return err == nil +} + +func setStartAtLogin(enable bool) error { + k, _, err := registry.CreateKey(registry.CURRENT_USER, runKey, registry.SET_VALUE) + if err != nil { + return err + } + defer k.Close() + if !enable { + if err := k.DeleteValue("KeepOrg"); err != nil && !errors.Is(err, registry.ErrNotExist) { + return err + } + return nil + } + exe, err := executable() + if err != nil { + return err + } + return k.SetStringValue("KeepOrg", `"`+exe+`" tray`) +} diff --git a/cmd/keeporg/autostart_xdg.go b/cmd/keeporg/autostart_xdg.go new file mode 100644 index 0000000..3c7af0d --- /dev/null +++ b/cmd/keeporg/autostart_xdg.go @@ -0,0 +1,51 @@ +//go:build linux || freebsd || openbsd || netbsd || dragonfly + +package main + +import ( + "fmt" + "os" + "path/filepath" +) + +// autostartPath is the XDG autostart entry every freedesktop session reads. +func autostartPath() string { + dir := os.Getenv("XDG_CONFIG_HOME") + if dir == "" { + home, _ := os.UserHomeDir() + dir = filepath.Join(home, ".config") + } + return filepath.Join(dir, "autostart", "keeporg.desktop") +} + +func startAtLoginEnabled() bool { + _, err := os.Stat(autostartPath()) + return err == nil +} + +func setStartAtLogin(enable bool) error { + path := autostartPath() + if !enable { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + exe, err := executable() + if err != nil { + return err + } + entry := fmt.Sprintf(`[Desktop Entry] +Type=Application +Name=KeepOrg +Comment=KeePass database synchronization +Exec="%s" tray +Icon=keeporg +Terminal=false +X-GNOME-Autostart-enabled=true +`, exe) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, []byte(entry), 0o644) +} diff --git a/cmd/keeporg/commands.go b/cmd/keeporg/commands.go index 0479ed9..d4ee7f7 100644 --- a/cmd/keeporg/commands.go +++ b/cmd/keeporg/commands.go @@ -10,6 +10,7 @@ import ( "log" "os" "os/exec" + "path/filepath" "strings" "text/tabwriter" "time" @@ -31,7 +32,8 @@ func session(path string) (*client.Config, *client.API, error) { if cfg.Token == "" { return nil, nil, client.ErrNoConfig } - return cfg, client.NewAPI(cfg.ServerURL, cfg.Token), nil + api, err := cfg.API() + return cfg, api, err } func cmdLogin(ctx context.Context, args []string) error { @@ -41,15 +43,14 @@ func cmdLogin(ctx context.Context, args []string) error { user := fs.String("user", "", "username (omit with -browser)") dir := fs.String("dir", "", "mirror directory (default "+client.DefaultMirrorDir()+")") browser := fs.String("browser", "", "approve in a browser instead: use -browser=sso for OIDC or -browser=yes") - label := fs.String("label", hostLabel(), "label shown in the server's session list") + label := fs.String("label", "", "label shown in the server's session list (default device_label from the config)") _ = fs.Parse(args) - cfg, err := client.LoadConfig(*path) - if err != nil && !errors.Is(err, client.ErrNoConfig) { - return err - } + // A setting that fails validation may be the one this login is about to + // fix, so only an unreadable file stops here; Validate runs below. + cfg, err := client.EnsureConfig(*path) if cfg == nil { - cfg = &client.Config{MirrorDir: client.DefaultMirrorDir(), PollSeconds: 2} + return err } if *server != "" { cfg.ServerURL = strings.TrimRight(*server, "/") @@ -60,20 +61,27 @@ func cmdLogin(ctx context.Context, args []string) error { if *dir != "" { cfg.MirrorDir = *dir } - if cfg.MirrorDir == "" { - cfg.MirrorDir = client.DefaultMirrorDir() + if *label != "" { + cfg.DeviceLabel = *label + } + if err := cfg.Validate(); err != nil { + return err } - api := client.NewAPI(cfg.ServerURL, "") + cfg.Token = "" + api, err := cfg.API() + if err != nil { + return err + } var sess *client.Session switch { case *browser != "": - sess, err = browserLogin(ctx, api, *label) + sess, err = browserLogin(ctx, api, cfg.DeviceLabel) default: if *user == "" { return errors.New("-user is required, or use -browser=yes") } - sess, err = passwordLogin(ctx, api, *user, *label) + sess, err = passwordLogin(ctx, api, *user, cfg.DeviceLabel) } if err != nil { return err @@ -86,7 +94,7 @@ func cmdLogin(ctx context.Context, args []string) error { if !sess.User.TOTPEnabled { fmt.Println("tip: run `keeporg totp` to add a second factor") } - fmt.Println("start the sync agent with `keeporg run`") + fmt.Println("start the sync agent with `keeporg tray`, or `keeporg run` without a desktop") return nil } @@ -169,13 +177,20 @@ func cmdRun(ctx context.Context, args []string) error { if err != nil { return err } - out := os.Stderr + var out io.Writer = os.Stderr + if cfg.LogFile != "" { + f, err := openLog(cfg.LogFile) + if err != nil { + return err + } + defer f.Close() + out = f + } lg := log.New(out, "", log.LstdFlags) if *quiet { lg = log.New(io.Discard, "", 0) } - mir, err := client.NewMirror(api, cfg.MirrorDir, lg, - time.Duration(cfg.PollSeconds)*time.Second) + mir, err := client.NewMirror(api, cfg.MirrorDir, lg, cfg.Poll()) if err != nil { return err } @@ -196,11 +211,15 @@ func cmdSync(ctx context.Context, args []string) error { if err != nil { return err } - mir, err := client.NewMirror(api, cfg.MirrorDir, log.New(os.Stderr, "", 0), - time.Duration(cfg.PollSeconds)*time.Second) + mir, err := client.NewMirror(api, cfg.MirrorDir, log.New(os.Stderr, "", 0), cfg.Poll()) if err != nil { return err } + unlock, err := client.LockDir(cfg.MirrorDir) + if err != nil { + return fmt.Errorf("%w; it keeps the files in step already", err) + } + defer unlock() if err := mir.SyncAll(ctx); err != nil { return err } @@ -719,10 +738,10 @@ func openBrowser(url string) { } } -func hostLabel() string { - h, err := os.Hostname() - if err != nil { - return "keeporg client" +// openLog opens a log file for appending, creating its directory. +func openLog(path string) (*os.File, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, err } - return h + return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) } diff --git a/cmd/keeporg/desktop.go b/cmd/keeporg/desktop.go new file mode 100644 index 0000000..a799f8e --- /dev/null +++ b/cmd/keeporg/desktop.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "os/exec" + "runtime" +) + +// homepage is where About points. +const homepage = "https://garage.rcmd.space/keeporg" + +// openPath opens a file, directory or URL with whatever the desktop +// associates with it. It is best effort: a caller that matters also logs or +// prints the target. +func openPath(target string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", target) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", target) + default: + cmd = exec.Command("xdg-open", target) + } + _ = cmd.Start() +} + +// openText opens a file in a text editor. JSON is associated with an IDE or +// a browser often enough that "open" is not good enough for a config file. +func openText(path string) { + switch runtime.GOOS { + case "darwin": + _ = exec.Command("open", "-t", path).Start() + case "windows": + _ = exec.Command("notepad", path).Start() + default: + openPath(path) + } +} + +// notify shows a desktop notification, best effort. Windows has none without +// a third-party library; the menu's first line carries the same news there. +func notify(title, message string) { + switch runtime.GOOS { + case "darwin": + _ = exec.Command("osascript", "-e", + fmt.Sprintf("display notification %q with title %q", message, title)).Start() + case "windows": + default: + _ = exec.Command("notify-send", "--app-name=KeepOrg", "--icon=keeporg", title, message).Start() + } +} + +// aboutText is the About dialog's body. +func aboutText(configPath string) string { + return fmt.Sprintf("KeepOrg %s\n\nKeePass database synchronization.\n\nConfiguration: %s\n%s", + Version, configPath, homepage) +} diff --git a/cmd/keeporg/main.go b/cmd/keeporg/main.go index 8a234f2..d999aa9 100644 --- a/cmd/keeporg/main.go +++ b/cmd/keeporg/main.go @@ -3,6 +3,9 @@ // It mirrors every KDBX database assigned to you into one directory as real // files, pushes each save to the server and applies what other clients push. // Point KeePassXC, KeePass2 or any compatible application at those files. +// +// Started without a command it is a system tray application; the commands +// below are the same engine for terminals, scripts and headless machines. package main import ( @@ -11,14 +14,19 @@ import ( "fmt" "os" "os/signal" + "path/filepath" + "strings" "syscall" "text/tabwriter" "cc.tiredsysadmin.keeporg/internal/client" ) -// Version is stamped at build time from versions.mk. -var Version = "dev" +// Version and AppID are stamped at build time from versions.mk. +var ( + Version = "dev" + AppID = "cc.tiredsysadmin.keeporg" +) type command struct { name string @@ -28,6 +36,7 @@ type command struct { func main() { cmds := []command{ + {"tray", "run as a system tray application (the default)", cmdTray}, {"login", "sign in to a server and choose the mirror directory", cmdLogin}, {"logout", "revoke this device's session", cmdLogout}, {"run", "keep the mirror in sync until stopped", cmdRun}, @@ -50,14 +59,19 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - if len(os.Args) < 2 { - usage(cmds) - os.Exit(2) + // A double-click, a launcher or a login item starts the tray. Old macOS + // releases pass a process serial number to an application bundle. + if len(os.Args) < 2 || strings.HasPrefix(os.Args[1], "-psn_") { + os.Args = append(os.Args[:1], "tray") } name := os.Args[1] - if name == "version" || name == "-version" || name == "--version" { + switch name { + case "version", "-version", "--version": fmt.Println("keeporg", Version) return + case "help", "-h", "-help", "--help": + usage(cmds) + return } for _, c := range cmds { if c.name != name { @@ -87,3 +101,13 @@ func usage(cmds []command) { fmt.Fprintf(os.Stderr, "\nRun `keeporg -h` for a command's flags.\n"+ "Configuration lives in %s\n", client.DefaultConfigPath()) } + +// executable is this program's resolved path, for login items that must +// survive the symlink a package manager or an app bundle put in front of it. +func executable() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + return filepath.EvalSymlinks(exe) +} diff --git a/cmd/keeporg/tray.go b/cmd/keeporg/tray.go new file mode 100644 index 0000000..589df70 --- /dev/null +++ b/cmd/keeporg/tray.go @@ -0,0 +1,229 @@ +//go:build !darwin || cgo + +package main + +import ( + "context" + "flag" + "log" + "runtime" + "sync" + "time" + + "fyne.io/systray" + + "cc.tiredsysadmin.keeporg/internal/icon" +) + +// maxDatabaseSlots bounds the Databases submenu. Menu items are created once +// and retitled, shown and hidden as the list changes, which every tray +// implementation handles; adding and removing them does not work everywhere. +const maxDatabaseSlots = 32 + +func cmdTray(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("tray", flag.ExitOnError) + path := configFlag(fs) + _ = fs.Parse(args) + + t := &trayApp{agent: newAgent(*path, notify), path: *path} + // The tray library and anything else that uses the standard logger + // write where the agent does. + log.SetOutput(t.agent.sink) + // SIGTERM, which a session sends at logout, quits as cleanly as the menu. + go func() { + <-ctx.Done() + systray.Quit() + }() + systray.Run(t.onReady, t.onExit) + return nil +} + +type trayApp struct { + agent *agent + path string + + header, openDir, syncNow, signIn, signOut, web *systray.MenuItem + noDatabases *systray.MenuItem + + mu sync.Mutex + slots [maxDatabaseSlots]struct { + item *systray.MenuItem + path string + } + last view +} + +func (t *trayApp) onReady() { + if runtime.GOOS == "darwin" { + systray.SetTemplateIcon(icon.PNG(icon.Tray(36, icon.Template)), icon.PNG(icon.Tray(36, icon.Brand))) + } else if runtime.GOOS == "windows" { + systray.SetIcon(icon.ICO(icon.Tray(16, icon.Brand), icon.Tray(32, icon.Brand), icon.Tray(48, icon.Brand))) + } else { + systray.SetIcon(icon.PNG(icon.Tray(64, icon.Brand))) + } + systray.SetTooltip("KeepOrg " + Version) + + t.header = systray.AddMenuItem("Starting…", "") + t.header.Disable() + systray.AddSeparator() + t.openDir = systray.AddMenuItem("Open Mirror Folder", "Show the synchronized .kdbx files") + dbs := systray.AddMenuItem("Databases", "Open a database in your KeePass application") + t.noDatabases = dbs.AddSubMenuItem("No databases", "") + t.noDatabases.Disable() + for i := range t.slots { + t.slots[i].item = dbs.AddSubMenuItem("", "") + t.slots[i].item.Hide() + go t.onDatabase(i) + } + t.syncNow = systray.AddMenuItem("Sync Now", "Reconcile every database immediately") + systray.AddSeparator() + t.signIn = systray.AddMenuItem("Sign In…", "Approve this device in a browser") + t.signOut = systray.AddMenuItem("Sign Out", "Revoke this device's session; the files stay") + t.web = systray.AddMenuItem("Open Web Interface", "Create vaults and manage access") + openConfig := systray.AddMenuItem("Open Config", "Edit the settings; changes apply when saved") + openLog := systray.AddMenuItem("Open Log", "") + login := systray.AddMenuItemCheckbox("Start at Login", "Start KeepOrg when you sign in", startAtLoginEnabled()) + systray.AddSeparator() + about := systray.AddMenuItem("About", "") + quit := systray.AddMenuItem("Quit", "Stop synchronizing and quit") + + t.agent.start() + t.refresh() + + go func() { + tick := time.NewTicker(time.Second) + defer tick.Stop() + for range tick.C { + t.agent.tick() + t.refresh() + } + }() + + go func() { + for { + select { + case <-t.openDir.ClickedCh: + openPath(t.view().MirrorDir) + case <-t.syncNow.ClickedCh: + t.agent.syncNow() + case <-t.signIn.ClickedCh: + go t.runSignIn() + case <-t.signOut.ClickedCh: + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.agent.signOut(ctx) + cancel() + t.refresh() + case <-t.web.ClickedCh: + if u := t.view().WebURL; u != "" { + openPath(u) + } + case <-openConfig.ClickedCh: + openText(t.path) + case <-openLog.ClickedCh: + openText(t.agent.logPath()) + case <-login.ClickedCh: + enable := !login.Checked() + if err := setStartAtLogin(enable); err != nil { + t.agent.log.Printf("start at login: %v", err) + notify("KeepOrg", "Could not change Start at Login: "+err.Error()) + } else if enable { + login.Check() + } else { + login.Uncheck() + } + case <-about.ClickedCh: + showAbout(t.path) + case <-quit.ClickedCh: + systray.Quit() + return + } + } + }() +} + +func (t *trayApp) onExit() { t.agent.stop() } + +func (t *trayApp) runSignIn() { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + t.refresh() + err := t.agent.signIn(ctx, openPath) + switch { + case err == errNoServer: + openText(t.path) + notify("KeepOrg", err.Error()) + case err != nil: + t.agent.log.Printf("sign in: %v", err) + notify("KeepOrg sign-in failed", err.Error()) + } + t.refresh() +} + +func (t *trayApp) onDatabase(i int) { + for range t.slots[i].item.ClickedCh { + t.mu.Lock() + path := t.slots[i].path + t.mu.Unlock() + if path != "" { + openPath(path) + } + } +} + +func (t *trayApp) view() view { + t.mu.Lock() + defer t.mu.Unlock() + return t.last +} + +// refresh brings the menu in line with the agent. Only what changed is sent, +// because on Linux every change is a D-Bus round trip. +func (t *trayApp) refresh() { + v := t.agent.view() + t.mu.Lock() + defer t.mu.Unlock() + old := t.last + t.last = v + + if v.Header != old.Header { + t.header.SetTitle(v.Header) + } + if v.Tooltip != old.Tooltip { + systray.SetTooltip(v.Tooltip) + } + showIf(t.signIn, !v.SignedIn && !v.Pending) + showIf(t.signOut, v.SignedIn) + enableIf(t.syncNow, v.SignedIn) + enableIf(t.web, v.WebURL != "") + showIf(t.noDatabases, len(v.Databases) == 0) + for i := range t.slots { + s := &t.slots[i] + if i >= len(v.Databases) { + s.path = "" + s.item.Hide() + continue + } + d := v.Databases[i] + if i >= len(old.Databases) || old.Databases[i] != d { + s.item.SetTitle(d.Label) + } + s.path = d.Path + s.item.Show() + } +} + +func showIf(item *systray.MenuItem, show bool) { + if show { + item.Show() + } else { + item.Hide() + } +} + +func enableIf(item *systray.MenuItem, enable bool) { + if enable { + item.Enable() + } else { + item.Disable() + } +} diff --git a/cmd/keeporg/tray_nocgo.go b/cmd/keeporg/tray_nocgo.go new file mode 100644 index 0000000..f7ccaf0 --- /dev/null +++ b/cmd/keeporg/tray_nocgo.go @@ -0,0 +1,14 @@ +//go:build darwin && !cgo + +package main + +import ( + "context" + "errors" +) + +// The macOS menu bar is reachable only through Cocoa, which needs cgo. A +// binary cross-compiled without it keeps every other command. +func cmdTray(context.Context, []string) error { + return errors.New("this build has no menu-bar support; build it on macOS with CGO_ENABLED=1, or use `keeporg run`") +} diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index c17b34c..b4b4272 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -25,13 +25,17 @@ trust boundary, and should treat the host accordingly. ``` cmd/keeporgd/ server entry point: flags, config file, bootstrap -cmd/keeporg/ desktop client: login, run, sync, status, administration +cmd/keeporg/ desktop client: the tray application (agent.go is its + logic, tray.go its menu), login, run, sync, status, + administration, start-at-login per platform internal/store/ domain model, JSON metadata store, ACL resolution internal/blob/ versioned KDBX payload storage on disk internal/kdbx/ KDBX 4 codec and the merge engine, with its own crypto internal/server/ HTTP surface: auth, OIDC, sync, admin, SSE hub, web, web administration, page templates -internal/client/ shared client engine: API wrapper, merge policy, mirror +internal/client/ shared client engine: API wrapper, merge policy, mirror, + configuration, the mirror directory lock +internal/icon/ the padlock, drawn in code: tray PNG/ICO, app icons internal/secret/ password verifiers, tokens, identifiers internal/totp/ RFC 6238 second factor android/ Android client: DocumentsProvider, sync service, UI @@ -39,6 +43,7 @@ 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 +tools/genicon/ writes the Linux icon theme and the macOS iconset versions.mk the one place every platform's version lives Makefile the only entry point for building and publishing ``` @@ -69,8 +74,9 @@ are cheap enough to call per request and easy to test exhaustively. ### Desktop client (`keeporg`) -`keeporg run` keeps a mirror directory (default `~/KeepOrg`) in step with the -server: +One binary, two faces. Started without a command it is a system tray +application; with a command it is the CLI. Both drive the same mirror, which +keeps a directory (default `~/KeepOrg`) in step with the server: - a rescan every two seconds notices that a KeePass application saved a file; - the server's event stream delivers other people's pushes immediately; @@ -81,6 +87,64 @@ All of the synchronization policy is the pure `decide()` table in `internal/client/decide.go`. Everything else in the mirror applies the action it returns. To change behaviour, change that table and its test. +**One agent per directory.** `Mirror.Run` takes an OS lock on +`.keeporg.lock` in the mirror directory (`flock`, or an unshared handle on +Windows) and holds it until it stops; `keeporg sync` takes it too. Two agents +on one directory would each take the other's writes for local saves and +push them back, and with a tray, a systemd unit and a Task Scheduler job all +available that is easy to set up by accident. The OS drops the lock when +the process dies, so a crash never leaves a directory locked. + +#### The tray + +The tray is split so that nothing that decides anything needs a desktop: + +- `agent.go` owns the configuration, the mirror's lifecycle, sign-in and + sign-out, and turns the agent's state into a `view` — the status line, the + database list, which items are enabled — in one pure function, + `buildView`. `statusNotice` decides which state changes are worth a + notification: conflicts, kept copies, merges, failures; never a routine + pull. Both are table-tested, and `agent_server_test.go` drives the agent + against a real in-process server through a sync and a server-side + revocation. +- `tray.go` is the menu: it builds the items once, and once a second calls + `agent.tick()` and applies whatever changed in the view. The database + submenu is 32 pre-made items that are retitled, shown and hidden, because + not every tray host copes with items being removed. + +Behaviour worth knowing: + +- **Sign-in is the device flow** the CLI's `-browser` option uses: the tray + opens the approval page and polls. It covers password, second factor and + single sign-on with no dialog in the tray itself. With no `server_url` + configured, Sign In opens the configuration file instead. +- **The configuration is the settings interface.** The first start writes + `config.json` with every key. A tick notices a saved edit by its + modification time; a file that no longer parses is reported once and the + running configuration kept; the mirror restarts only when a setting it was + built from changed (`mirrorSettingsChanged`). The tray's own writes record + the new modification time, so signing in does not look like an edit. +- **A revoked session signs the device out.** When the server answers + `unauthorized`, the agent stops, clears the token from the file and says + so, rather than retrying every two seconds forever. +- **The mirror's goroutine never takes the agent's lock.** Stopping the + mirror holds that lock while it waits for the goroutine; the goroutine + reports how it ended through its own small `runErr` instead. +- **SIGTERM quits** the same way the menu does, which matters at logout. +- **Start at Login** writes a LaunchAgent (`.plist`) on macOS, an XDG + autostart entry on Linux and the BSDs, and the `HKCU\…\Run` value on + Windows, each running `keeporg tray` from the resolved executable path. +- **Platform specifics.** macOS gets a template icon, so the menu bar + recolours it; Windows gets an ICO. Notifications use `osascript` and + `notify-send`; Windows has none yet, and the status line carries the same + news. The About box is AppleScript, zenity or kdialog, or a Windows + message box. + +Every client setting, with its default, is in the README's configuration +table and in the comments on `client.Config`. `LoadConfig` reads a file over +a fully defaulted `Config`, so a key a file leaves out keeps its default — +that is how an 0.4 file with no `notifications` key still means `true`. + ### Android client `KeepOrgProvider` is a `DocumentsProvider`, so the mirrored files appear in @@ -421,13 +485,26 @@ be left behind. ## Decisions worth knowing -**No third-party Go dependencies, no cgo.** The whole server and desktop -client build with `CGO_ENABLED=0` from the standard library: SSE instead of a -WebSocket library, `crypto/pbkdf2` (new in Go 1.24) for account passwords, -hand-written ChaCha20, BLAKE2b and Argon2 for the KDBX format, 60 lines of -HMAC for TOTP, `html/template` for the pages. -Cross-compiling to five platforms is then one `go build` each, and there is -no dependency tree to audit for a program that guards passwords. +**No third-party Go dependencies in the server, no cgo outside the macOS +tray.** The server builds with `CGO_ENABLED=0` from the standard library +alone: SSE instead of a WebSocket library, `crypto/pbkdf2` (new in Go 1.24) +for account passwords, hand-written ChaCha20, BLAKE2b and Argon2 for the +KDBX format, 60 lines of HMAC for TOTP, `html/template` for the pages. There +is no dependency tree to audit in the program that holds the vault keys; +`go list -deps ./cmd/keeporgd` shows only the standard library and this +module, and the container builds without fetching anything. + +The desktop client takes exactly one, `fyne.io/systray` (with +`godbus/dbus` and `golang.org/x/sys` under it), because a tray icon cannot +be had otherwise for less than several thousand lines: Linux and the BSDs +speak StatusNotifierItem over D-Bus, Windows needs Win32, and the macOS menu +bar is reachable only through Cocoa. It was chosen over +`getlantern/systray`, which `midi-webhook` uses, because it is maintained and +needs no cgo or GTK on Linux: Linux, BSD and Windows clients still +cross-compile from anywhere. Only macOS needs cgo, so the Makefile links the +darwin client with cgo when it is built on a Mac; built elsewhere it keeps +every command but `tray` (`tray_nocgo.go`). The client only moves +ciphertext, so the trade is in the program that never sees a key. **A JSON document instead of SQLite.** The metadata is hundreds of records, never thousands: users, groups, grants. One writer with atomic rename gives @@ -616,7 +693,19 @@ Application identifiers follow `cc.tiredsysadmin..`: `.windows.keeporg`, `.bsd.keeporg`, and `.linux.keeporgd` for the server package. -Desktop and server artifacts stay local (`dist/`). Android publishing is +Desktop and server artifacts stay local (`dist/`): + +- `make dmg` (on a Mac): `KeepOrg.app`, whose executable is the client + itself, with an icon made by `iconutil` from `tools/genicon`'s iconset and + `LSUIElement` set, so there is a menu-bar item and no Dock icon. +- `make deb`: `/usr/bin/keeporg`, a desktop entry that starts the tray, the + hicolor icons, and the systemd user unit for headless `keeporg run`. +- `make windows`: `keeporg.exe`, a console program for the commands, and + `keeporg-tray.exe`, the same code linked `-H windowsgui` so the tray opens + no console window. They cannot be called `keeporg.exe` and `KeepOrg.exe`: + Windows file names are case-insensitive. + +Android publishing is manual, never automatic: ```sh @@ -661,7 +750,18 @@ What the suite actually covers: - `internal/client` for the decision table, and for the mirror driven against a real in-process server: adopting a local file, following a remote push, server-wins, client-wins, manual, read-only restore, server-side rename, - and an update arriving over the event stream. + and an update arriving over the event stream; for the configuration's + defaults, validation and `~` expansion; and for the mirror lock admitting + one agent. +- `cmd/keeporg` for the tray's status line and database list, which state + changes notify, configuration reload and a broken edit, the notifications + setting, and the agent against a real server through a sync and a + revocation. +- `internal/icon` for the padlock's shape and the ICO container. + +The tray's menu itself has no automated test: it needs a desktop session. +It was run on macOS against a local server; on Linux and Windows it has been +compiled, not run. The Android module is checked by `make android-lint`, which is kept at zero findings, and `make android` builds the unsigned release APK. See @@ -670,6 +770,16 @@ what an arm64 Linux build host needs. ## Known limits and the next things to do +- **The tray has only been run on macOS.** The Linux, BSD and Windows builds + compile and their logic is tested, but nobody has looked at the icon in a + KDE panel or the Windows notification area yet. +- **GNOME shows no tray** without the AppIndicator extension. `keeporg run` + or the systemd user unit work there regardless. +- **No notifications on Windows.** A toast needs either a COM interop layer + or PowerShell; the status line shows the same states meanwhile. +- **The DMG is per architecture.** A universal `.app` needs `lipo` over both + cgo builds, which `midi-webhook`'s Makefile shows how to do. + - **One server node.** The JSON store is single-writer by design. Horizontal scale means replacing `internal/store`. - **Merging requires the server to hold the vault key.** That is inherent, not diff --git a/go.mod b/go.mod index dd48345..937a80a 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,10 @@ module cc.tiredsysadmin.keeporg go 1.24 + +require ( + fyne.io/systray v1.12.2 + golang.org/x/sys v0.27.0 +) + +require github.com/godbus/dbus/v5 v5.2.2 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..eeb677a --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= +fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/internal/client/config.go b/internal/client/config.go index fe0cc76..a441aaa 100644 --- a/internal/client/config.go +++ b/internal/client/config.go @@ -1,22 +1,48 @@ package client import ( + "crypto/tls" + "crypto/x509" "encoding/json" "errors" + "fmt" + "net/http" + "net/url" "os" "path/filepath" + "strings" + "time" ) -// Config is what a client remembers between runs. The session token lives -// here; the KDBX master passwords never do, because the client never opens a -// database, it only moves bytes. +// Config is what a client remembers between runs, and every setting a user +// can change. The session token lives here; the KDBX master passwords never +// do, because the client never opens a database, it only moves bytes. +// +// The file is written with every field present, so editing it is the whole +// of the client's settings interface: the tray's "Open Config" opens it, and +// the tray picks up a saved change without restarting. type Config struct { + // ServerURL is the server's base URL, as a browser sees it. ServerURL string `json:"server_url"` - Token string `json:"token"` - Username string `json:"username"` + // Username and Token are written by signing in, not by hand. + Username string `json:"username"` + Token string `json:"token"` + // DeviceLabel names this device in the server's session list. + DeviceLabel string `json:"device_label"` + // MirrorDir is where the databases appear as .kdbx files. MirrorDir string `json:"mirror_dir"` // PollSeconds is how often the mirror is rescanned for local saves. PollSeconds int `json:"poll_seconds"` + // RequestTimeoutSeconds bounds one API call. The event stream is exempt. + RequestTimeoutSeconds int `json:"request_timeout_seconds"` + // CAFile is a PEM bundle trusted in addition to the system roots, for a + // server behind a private certificate authority. + CAFile string `json:"ca_file"` + // LogFile is where the agent logs. Empty means standard error for + // `keeporg run`, and keeporg.log beside this file for the tray. + LogFile string `json:"log_file"` + // Notifications turns the tray's desktop notifications on or off. + Notifications bool `json:"notifications"` } // ErrNoConfig means the client has not been logged in yet. @@ -40,7 +66,24 @@ func DefaultMirrorDir() string { return filepath.Join(home, "KeepOrg") } -// LoadConfig reads a configuration file. +// DefaultConfig is a configuration with every setting at its default and no +// server. +func DefaultConfig() *Config { + label, err := os.Hostname() + if err != nil || label == "" { + label = "keeporg client" + } + return &Config{ + DeviceLabel: label, + MirrorDir: DefaultMirrorDir(), + PollSeconds: 2, + RequestTimeoutSeconds: 60, + Notifications: true, + } +} + +// LoadConfig reads a configuration file. A setting the file leaves out keeps +// its default, so an older file reads the same as it always did. func LoadConfig(path string) (*Config, error) { b, err := os.ReadFile(path) if os.IsNotExist(err) { @@ -49,19 +92,85 @@ func LoadConfig(path string) (*Config, error) { if err != nil { return nil, err } - var c Config - if err := json.Unmarshal(b, &c); err != nil { - return nil, err + c := DefaultConfig() + if err := json.Unmarshal(b, c); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) } + def := DefaultConfig() if c.MirrorDir == "" { - c.MirrorDir = DefaultMirrorDir() + c.MirrorDir = def.MirrorDir + } + if c.DeviceLabel == "" { + c.DeviceLabel = def.DeviceLabel } if c.PollSeconds <= 0 { - c.PollSeconds = 2 + c.PollSeconds = def.PollSeconds } - return &c, nil + if c.RequestTimeoutSeconds <= 0 { + c.RequestTimeoutSeconds = def.RequestTimeoutSeconds + } + c.ServerURL = strings.TrimRight(c.ServerURL, "/") + c.MirrorDir = expandHome(c.MirrorDir) + c.CAFile = expandHome(c.CAFile) + c.LogFile = expandHome(c.LogFile) + return c, c.Validate() } +// EnsureConfig returns the configuration at path, writing a default one +// first if there is none, so a user always has a complete file to edit. +func EnsureConfig(path string) (*Config, error) { + c, err := LoadConfig(path) + if !errors.Is(err, ErrNoConfig) { + return c, err + } + c = DefaultConfig() + return c, c.Save(path) +} + +// Validate reports the first setting that cannot work. +func (c *Config) Validate() error { + if c.ServerURL != "" { + u, err := url.Parse(c.ServerURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return fmt.Errorf("server_url %q must be an http:// or https:// URL", c.ServerURL) + } + } + if c.CAFile != "" { + if _, err := os.Stat(c.CAFile); err != nil { + return fmt.Errorf("ca_file: %w", err) + } + } + return nil +} + +// API returns a client for the configured server, signed in with the stored +// token when there is one. +func (c *Config) API() (*API, error) { + api := NewAPI(c.ServerURL, c.Token) + api.HTTP.Timeout = time.Duration(c.RequestTimeoutSeconds) * time.Second + if c.CAFile == "" { + return api, nil + } + pem, err := os.ReadFile(c.CAFile) + if err != nil { + return nil, fmt.Errorf("ca_file: %w", err) + } + pool, err := x509.SystemCertPool() + if err != nil { + pool = x509.NewCertPool() + } + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("ca_file %s holds no PEM certificates", c.CAFile) + } + tr := http.DefaultTransport.(*http.Transport).Clone() + tr.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12} + api.HTTP.Transport = tr + return api, nil +} + +// Poll is PollSeconds as a duration. +func (c *Config) Poll() time.Duration { return time.Duration(c.PollSeconds) * time.Second } + // Save writes the configuration with owner-only permissions, since it holds // a bearer token. func (c *Config) Save(path string) error { @@ -78,3 +187,16 @@ func (c *Config) Save(path string) error { } return os.Rename(tmp, path) } + +// expandHome turns a leading ~ into the home directory, since the file is +// meant to be edited by hand. +func expandHome(p string) string { + if p != "~" && !strings.HasPrefix(p, "~/") && !strings.HasPrefix(p, `~\`) { + return p + } + home, err := os.UserHomeDir() + if err != nil { + return p + } + return filepath.Join(home, p[1:]) +} diff --git a/internal/client/config_test.go b/internal/client/config_test.go new file mode 100644 index 0000000..3de7a18 --- /dev/null +++ b/internal/client/config_test.go @@ -0,0 +1,121 @@ +package client + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEnsureConfigWritesEverySetting(t *testing.T) { + path := filepath.Join(t.TempDir(), "keeporg", "config.json") + c, err := EnsureConfig(path) + if err != nil { + t.Fatal(err) + } + if c.PollSeconds != 2 || c.RequestTimeoutSeconds != 60 || !c.Notifications { + t.Fatalf("defaults not applied: %+v", c) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var keys map[string]any + if err := json.Unmarshal(b, &keys); err != nil { + t.Fatal(err) + } + for _, k := range []string{"server_url", "username", "token", "device_label", "mirror_dir", + "poll_seconds", "request_timeout_seconds", "ca_file", "log_file", "notifications"} { + if _, ok := keys[k]; !ok { + t.Errorf("written config lacks %q", k) + } + } + if fi, _ := os.Stat(path); fi.Mode().Perm() != 0o600 { + t.Errorf("config mode %v, want 0600", fi.Mode().Perm()) + } +} + +func TestLoadConfigKeepsDefaultsForMissingKeys(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + // What a 0.4 client wrote: no notifications key, which must not read as + // false. + old := `{"server_url":"https://k.example.com/","token":"t","username":"u","mirror_dir":"","poll_seconds":0}` + if err := os.WriteFile(path, []byte(old), 0o600); err != nil { + t.Fatal(err) + } + c, err := LoadConfig(path) + if err != nil { + t.Fatal(err) + } + if !c.Notifications || c.PollSeconds != 2 || c.MirrorDir == "" || c.DeviceLabel == "" { + t.Fatalf("defaults lost: %+v", c) + } + if c.ServerURL != "https://k.example.com" { + t.Fatalf("trailing slash kept: %q", c.ServerURL) + } +} + +func TestLoadConfigExpandsHome(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skip(err) + } + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(`{"mirror_dir":"~/Vaults","log_file":"~/keeporg.log"}`), 0o600); err != nil { + t.Fatal(err) + } + c, err := LoadConfig(path) + if err != nil { + t.Fatal(err) + } + if c.MirrorDir != filepath.Join(home, "Vaults") || c.LogFile != filepath.Join(home, "keeporg.log") { + t.Fatalf("~ not expanded: %q %q", c.MirrorDir, c.LogFile) + } +} + +func TestLoadConfigRejectsUnusableSettings(t *testing.T) { + for name, body := range map[string]string{ + "scheme": `{"server_url":"keeporg.example.com"}`, + "ca_file": `{"ca_file":"/nonexistent/ca.pem"}`, + "syntax": `{"server_url":`, + } { + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadConfig(path); err == nil || errors.Is(err, ErrNoConfig) { + t.Errorf("%s: want a validation error, got %v", name, err) + } + } +} + +func TestConfigAPIRejectsEmptyCAFile(t *testing.T) { + ca := filepath.Join(t.TempDir(), "ca.pem") + if err := os.WriteFile(ca, []byte("not a certificate"), 0o600); err != nil { + t.Fatal(err) + } + c := DefaultConfig() + c.ServerURL, c.CAFile = "https://k.example.com", ca + if _, err := c.API(); err == nil || !strings.Contains(err.Error(), "no PEM") { + t.Fatalf("want a PEM error, got %v", err) + } +} + +func TestLockDirAdmitsOneAgent(t *testing.T) { + dir := t.TempDir() + unlock, err := LockDir(dir) + if err != nil { + t.Fatal(err) + } + if _, err := LockDir(dir); !errors.Is(err, ErrMirrorBusy) { + t.Fatalf("second lock: want ErrMirrorBusy, got %v", err) + } + unlock() + again, err := LockDir(dir) + if err != nil { + t.Fatalf("lock after release: %v", err) + } + again() +} diff --git a/internal/client/lock.go b/internal/client/lock.go new file mode 100644 index 0000000..2bf9175 --- /dev/null +++ b/internal/client/lock.go @@ -0,0 +1,32 @@ +package client + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +// lockFile marks a mirror directory as being synchronized by one agent. Two +// agents on one directory would each think the other's writes were local +// saves and push them back, so the tray, `keeporg run` and a service unit +// started side by side must not all sync. +const lockFile = ".keeporg.lock" + +// ErrMirrorBusy means another agent already holds the mirror directory. +var ErrMirrorBusy = errors.New("another keeporg agent is already synchronizing this directory") + +// LockDir takes the mirror directory's lock for this process. The operating +// system releases it when the process exits, however it exits, so a crash +// never leaves a directory locked. +func LockDir(dir string) (unlock func(), err error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + path := filepath.Join(dir, lockFile) + unlock, err = lockPath(path) + if errors.Is(err, ErrMirrorBusy) { + return nil, fmt.Errorf("%s: %w", dir, err) + } + return unlock, err +} diff --git a/internal/client/lock_other.go b/internal/client/lock_other.go new file mode 100644 index 0000000..13fdd02 --- /dev/null +++ b/internal/client/lock_other.go @@ -0,0 +1,6 @@ +//go:build !(linux || darwin || freebsd || openbsd || netbsd || dragonfly || windows) + +package client + +// lockPath is a no-op where there is no advisory lock to take. +func lockPath(string) (func(), error) { return func() {}, nil } diff --git a/internal/client/lock_unix.go b/internal/client/lock_unix.go new file mode 100644 index 0000000..241c6c9 --- /dev/null +++ b/internal/client/lock_unix.go @@ -0,0 +1,24 @@ +//go:build linux || darwin || freebsd || openbsd || netbsd || dragonfly + +package client + +import ( + "errors" + "os" + "syscall" +) + +func lockPath(path string) (func(), error) { + f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + f.Close() + if errors.Is(err, syscall.EWOULDBLOCK) { + return nil, ErrMirrorBusy + } + return nil, err + } + return func() { f.Close() }, nil +} diff --git a/internal/client/lock_windows.go b/internal/client/lock_windows.go new file mode 100644 index 0000000..46d22e4 --- /dev/null +++ b/internal/client/lock_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package client + +import ( + "errors" + "syscall" +) + +// lockPath opens the file with no sharing at all, which Windows enforces +// until the handle closes. +func lockPath(path string) (func(), error) { + p, err := syscall.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + h, err := syscall.CreateFile(p, syscall.GENERIC_READ|syscall.GENERIC_WRITE, + 0, nil, syscall.OPEN_ALWAYS, syscall.FILE_ATTRIBUTE_HIDDEN, 0) + if err != nil { + if errors.Is(err, errSharingViolation) { + return nil, ErrMirrorBusy + } + return nil, err + } + return func() { syscall.CloseHandle(h) }, nil +} + +// errSharingViolation is ERROR_SHARING_VIOLATION, which package syscall +// does not name. +const errSharingViolation = syscall.Errno(32) diff --git a/internal/client/mirror.go b/internal/client/mirror.go index 8507b87..e954e9c 100644 --- a/internal/client/mirror.go +++ b/internal/client/mirror.go @@ -57,9 +57,17 @@ type Mirror struct { log *log.Logger poll time.Duration + // kick asks Run for a reconcile now. It holds at most one request, so + // a burst of them costs one pass. + kick chan struct{} + mu sync.Mutex st map[string]*fileState status map[string]*Status + // lastSync and lastErr describe the most recent SyncAll as a whole, which + // a per-database status cannot: an unreachable server has no databases. + lastSync time.Time + lastErr error } // NewMirror prepares dir and loads any previous state. @@ -70,7 +78,7 @@ func NewMirror(api *API, dir string, lg *log.Logger, poll time.Duration) (*Mirro if poll <= 0 { poll = 2 * time.Second } - m := &Mirror{api: api, dir: dir, log: lg, poll: poll, + m := &Mirror{api: api, dir: dir, log: lg, poll: poll, kick: make(chan struct{}, 1), st: map[string]*fileState{}, status: map[string]*Status{}} b, err := os.ReadFile(filepath.Join(dir, stateFile)) switch { @@ -88,18 +96,26 @@ func NewMirror(api *API, dir string, lg *log.Logger, poll time.Duration) (*Mirro // Dir is the mirror directory. func (m *Mirror) Dir() string { return m.dir } -// Run reconciles until ctx is done, driven by server push events and by a -// local rescan for saves made by a KeePass application. -func (m *Mirror) Run(ctx context.Context) error { - trigger := make(chan struct{}, 1) - wake := func() { - select { - case trigger <- struct{}{}: - default: - } +// Kick asks a running mirror to reconcile now instead of at the next rescan. +func (m *Mirror) Kick() { + select { + case m.kick <- struct{}{}: + default: } +} - go m.listen(ctx, wake) +// Run reconciles until ctx is done, driven by server push events and by a +// local rescan for saves made by a KeePass application. It holds the mirror +// directory's lock throughout, so a second agent on the same directory fails +// to start instead of racing this one. +func (m *Mirror) Run(ctx context.Context) error { + unlock, err := LockDir(m.dir) + if err != nil { + return err + } + defer unlock() + + go m.listen(ctx, m.Kick) if err := m.SyncAll(ctx); err != nil { m.log.Printf("sync: %v", err) @@ -114,7 +130,7 @@ func (m *Mirror) Run(ctx context.Context) error { if err := m.SyncAll(ctx); err != nil { m.log.Printf("sync: %v", err) } - case <-trigger: + case <-m.kick: // Let a burst of events settle before reconciling. time.Sleep(150 * time.Millisecond) if err := m.SyncAll(ctx); err != nil { @@ -155,10 +171,14 @@ func (m *Mirror) listen(ctx context.Context, wake func()) { func (m *Mirror) SyncAll(ctx context.Context) error { dbs, err := m.api.Databases(ctx) if err != nil { + m.mu.Lock() + m.lastSync, m.lastErr = time.Now(), err + m.mu.Unlock() return err } m.mu.Lock() defer m.mu.Unlock() + defer func() { m.lastSync = time.Now() }() seen := map[string]bool{} var firstErr error @@ -184,9 +204,20 @@ func (m *Mirror) SyncAll(ctx context.Context) error { if err := m.saveState(); err != nil && firstErr == nil { firstErr = err } + // A failure of one database is in its own status; lastErr is for what + // stopped the whole pass. + m.lastErr = nil return firstErr } +// LastSync reports when the last pass finished, and the error that stopped +// it from reaching the server, if one did. +func (m *Mirror) LastSync() (time.Time, error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.lastSync, m.lastErr +} + // Status reports the current state of each mirrored database. func (m *Mirror) Status() []Status { m.mu.Lock() diff --git a/internal/icon/icon.go b/internal/icon/icon.go new file mode 100644 index 0000000..084f428 --- /dev/null +++ b/internal/icon/icon.go @@ -0,0 +1,149 @@ +// Package icon draws the KeepOrg padlock at any size. +// +// The glyph is geometry rather than a shipped image, so one definition serves +// the tray (a small PNG, or an ICO on Windows, made at run time) and the +// packages (the Linux icon theme and the macOS .icns, written at build time +// by tools/genicon). +package icon + +import ( + "bytes" + "encoding/binary" + "image" + "image/color" + "image/png" + "math" +) + +// design is the edge length of the coordinate system the shapes are drawn in. +const design = 32.0 + +// subsamples is the per-axis supersampling rate that antialiases the curves. +const subsamples = 4 + +var ( + // Brand is the padlock's colour where it stands on its own, in a tray + // that may be light or dark. + Brand = color.NRGBA{R: 0x2f, G: 0x6f, B: 0xd6, A: 0xff} + // Template is the macOS menu-bar form: black, which the system recolours + // for light and dark menu bars from the alpha channel alone. + Template = color.NRGBA{A: 0xff} +) + +// lock reports whether a design-space point is inside the padlock. +func lock(x, y float64) bool { + if keyhole(x, y) { + return false + } + // Body: a rounded rectangle. + if roundedRect(x, y, 5, 14.5, 27, 29, 3) { + return true + } + // Shackle: the top half of a ring, with straight legs into the body. + d := math.Hypot(x-16, y-12) + if y <= 12 && d >= 5 && d <= 8.5 { + return true + } + return y > 12 && y < 15 && ((x >= 7.5 && x <= 11) || (x >= 21 && x <= 24.5)) +} + +func keyhole(x, y float64) bool { + return math.Hypot(x-16, y-20.5) <= 2.6 || (x >= 15 && x <= 17 && y >= 20.5 && y <= 25.5) +} + +// badge is the app icon's background: a rounded square filling the canvas. +func badge(x, y float64) bool { return roundedRect(x, y, 0.5, 0.5, 31.5, 31.5, 7) } + +func roundedRect(x, y, x0, y0, x1, y1, r float64) bool { + if x < x0 || x > x1 || y < y0 || y > y1 { + return false + } + cx := math.Max(x0+r, math.Min(x, x1-r)) + cy := math.Max(y0+r, math.Min(y, y1-r)) + return math.Hypot(x-cx, y-cy) <= r +} + +// coverage estimates how much of an output pixel a shape covers. +func coverage(in func(x, y float64) bool, px, py int, scale float64) float64 { + step := scale / subsamples + hits := 0 + for sy := 0; sy < subsamples; sy++ { + for sx := 0; sx < subsamples; sx++ { + if in(float64(px)*scale+(float64(sx)+0.5)*step, float64(py)*scale+(float64(sy)+0.5)*step) { + hits++ + } + } + } + return float64(hits) / (subsamples * subsamples) +} + +// Tray draws the bare padlock in c on a transparent background, for the +// system tray or the macOS menu bar. +func Tray(size int, c color.NRGBA) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, max(size, 0), max(size, 0))) + scale := design / float64(max(size, 1)) + for py := 0; py < size; py++ { + for px := 0; px < size; px++ { + if a := coverage(lock, px, py, scale); a > 0 { + img.SetNRGBA(px, py, color.NRGBA{c.R, c.G, c.B, uint8(float64(c.A)*a + 0.5)}) + } + } + } + return img +} + +// App draws the application icon: a white padlock on a brand-coloured +// rounded square, for launchers, the Dock and file managers. +func App(size int) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, max(size, 0), max(size, 0))) + scale := design / float64(max(size, 1)) + // The padlock is drawn smaller inside the badge, around its centre. + inner := func(x, y float64) bool { return lock((x-16)/0.72+16, (y-16.5)/0.72+16) } + for py := 0; py < size; py++ { + for px := 0; px < size; px++ { + bg := coverage(badge, px, py, scale) + if bg == 0 { + continue + } + fg := coverage(inner, px, py, scale) + mix := func(b, w uint8) uint8 { return uint8(float64(b)*(1-fg) + float64(w)*fg + 0.5) } + img.SetNRGBA(px, py, color.NRGBA{mix(Brand.R, 0xff), mix(Brand.G, 0xff), + mix(Brand.B, 0xff), uint8(255*bg + 0.5)}) + } + } + return img +} + +// PNG encodes an image. Encoding an in-memory NRGBA cannot fail. +func PNG(img image.Image) []byte { + var b bytes.Buffer + _ = png.Encode(&b, img) + return b.Bytes() +} + +// ICO wraps PNG-encoded images in an ICO container, which is what the +// Windows tray takes. PNG entries are valid in ICO since Windows Vista. +func ICO(images ...*image.NRGBA) []byte { + var hdr, data bytes.Buffer + le := binary.LittleEndian + _ = binary.Write(&hdr, le, [3]uint16{0, 1, uint16(len(images))}) + offset := 6 + 16*len(images) + for _, img := range images { + p := PNG(img) + w, h := img.Bounds().Dx(), img.Bounds().Dy() + dim := func(n int) uint8 { // 0 means 256 in an ICO directory entry + if n >= 256 { + return 0 + } + return uint8(n) + } + _ = binary.Write(&hdr, le, struct { + W, H, Colors, Reserved uint8 + Planes, Bits uint16 + Size, Offset uint32 + }{dim(w), dim(h), 0, 0, 1, 32, uint32(len(p)), uint32(offset)}) + data.Write(p) + offset += len(p) + } + return append(hdr.Bytes(), data.Bytes()...) +} diff --git a/internal/icon/icon_test.go b/internal/icon/icon_test.go new file mode 100644 index 0000000..d544ae5 --- /dev/null +++ b/internal/icon/icon_test.go @@ -0,0 +1,54 @@ +package icon + +import ( + "bytes" + "encoding/binary" + "image/png" + "testing" +) + +func TestTrayIsOpaqueOnTheBodyAndClearInTheKeyhole(t *testing.T) { + img := Tray(32, Brand) + if a := img.NRGBAAt(8, 26).A; a != 255 { + t.Errorf("body pixel alpha %d, want 255", a) + } + if a := img.NRGBAAt(16, 20).A; a != 0 { + t.Errorf("keyhole pixel alpha %d, want 0", a) + } + if a := img.NRGBAAt(1, 1).A; a != 0 { + t.Errorf("corner pixel alpha %d, want 0", a) + } +} + +func TestAppHasAWhiteLockOnTheBadge(t *testing.T) { + img := App(64) + if c := img.NRGBAAt(32, 50); c.R != 255 || c.A != 255 { + t.Errorf("lock body %v, want opaque white", c) + } + if c := img.NRGBAAt(4, 32); c != Brand { + t.Errorf("badge edge %v, want %v", c, Brand) + } +} + +func TestICOHoldsEachSizeAsPNG(t *testing.T) { + ico := ICO(Tray(16, Brand), Tray(256, Brand)) + var hdr [3]uint16 + if err := binary.Read(bytes.NewReader(ico), binary.LittleEndian, &hdr); err != nil { + t.Fatal(err) + } + if hdr != [3]uint16{0, 1, 2} { + t.Fatalf("header %v", hdr) + } + for i, want := range []int{16, 256} { + e := ico[6+16*i:] + size := binary.LittleEndian.Uint32(e[8:]) + off := binary.LittleEndian.Uint32(e[12:]) + img, err := png.Decode(bytes.NewReader(ico[off : off+size])) + if err != nil { + t.Fatalf("entry %d: %v", i, err) + } + if img.Bounds().Dx() != want { + t.Errorf("entry %d is %dpx, want %d", i, img.Bounds().Dx(), want) + } + } +} diff --git a/packaging/deb/client/changelog b/packaging/deb/client/changelog index 3542a87..a12a1a7 100644 --- a/packaging/deb/client/changelog +++ b/packaging/deb/client/changelog @@ -1,3 +1,16 @@ +keeporg (0.5.0) unstable; urgency=medium + + * KeepOrg is now a system tray application: started without a command (or + from the applications menu) it shows a padlock whose menu signs in + through the browser, lists each database's state, opens the mirror + folder, the web interface, the settings and the log, and starts at + login. The configuration file now holds every setting (device_label, + request_timeout_seconds, ca_file, log_file, notifications), is written + in full on first start, and is reloaded when saved. Only one agent + synchronizes a mirror directory at a time. + + -- Azer Abdullaev Mon, 02 Nov 2026 11:22:07 +0100 + keeporg (0.4.0) unstable; urgency=medium * keeporg db -delete now asks for your account password diff --git a/packaging/deb/client/control.in b/packaging/deb/client/control.in index a3b183b..c810515 100644 --- a/packaging/deb/client/control.in +++ b/packaging/deb/client/control.in @@ -3,6 +3,8 @@ Version: @VERSION@ Section: utils Priority: optional Architecture: @ARCH@ +Recommends: libnotify-bin +Suggests: keepassxc, zenity | kdialog Maintainer: tiredsysadmin Homepage: https://garage.rcmd.space/keeporg Description: KeePass database synchronization client @@ -13,5 +15,10 @@ Description: KeePass database synchronization client Each save is pushed to the server and changes other people push are applied locally. The client never opens a database and never sees a master password. . - A systemd user unit is included: enable it with - `systemctl --user enable --now keeporg`. + KeepOrg starts as a system tray application from the applications menu; its + menu signs in, shows each database's state and opens the settings. The tray + needs a StatusNotifierItem host: KDE, XFCE, Cinnamon and most panels have + one, GNOME needs the AppIndicator extension. + . + Without a desktop, `keeporg run` is the same agent, and a systemd user unit + is included for it: `systemctl --user enable --now keeporg`. diff --git a/packaging/linux/keeporg.desktop b/packaging/linux/keeporg.desktop new file mode 100644 index 0000000..f508b28 --- /dev/null +++ b/packaging/linux/keeporg.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Application +Name=KeepOrg +GenericName=Password database synchronization +Comment=Keep shared KeePass databases in step with a KeepOrg server +Exec=keeporg tray +Icon=keeporg +Terminal=false +Categories=Utility;Security; +Keywords=keepass;kdbx;password;sync; +StartupNotify=false diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index c91f18f..773a897 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -15,10 +15,12 @@ CFBundlePackageType APPL CFBundleExecutable + keeporg + CFBundleIconFile KeepOrg LSMinimumSystemVersion 11.0 - + LSUIElement NSHighResolutionCapable diff --git a/packaging/windows/README.txt b/packaging/windows/README.txt index bf03811..e7750b0 100644 --- a/packaging/windows/README.txt +++ b/packaging/windows/README.txt @@ -1,27 +1,53 @@ KeepOrg for Windows =================== -keeporg.exe is the synchronization agent and the administration command. +keeporg-tray.exe is the tray application. keeporg.exe is the same program for a +command prompt: administration commands, and the agent without a tray. First run --------- +1. Copy both files to %LOCALAPPDATA%\Programs\KeepOrg and start keeporg-tray.exe. + A padlock appears in the notification area. +2. Choose Open Config from its menu, set "server_url" to your server, for + example "https://keeporg.example.com", and save. +3. Choose Sign In... and approve the code in the browser that opens, with + your password or single sign-on. + +Your databases appear in %UserProfile%\KeepOrg. Open them with KeePass, +KeePassXC or any compatible application: every save is pushed to the +server, and changes made by other people are applied to the files. The +Databases submenu shows each one's state and opens it. + +Tick Start at Login to start KeepOrg when you sign in to Windows. + +Settings +-------- +%AppData%\keeporg\config.json holds every setting, and the tray applies an +edit as soon as it is saved: + + server_url the server, as a browser sees it + device_label this computer's name in the server's session list + mirror_dir where the .kdbx files are kept + poll_seconds how often local saves are looked for + request_timeout_seconds the limit on one request to the server + ca_file a PEM bundle, for a server behind a private CA + log_file the log; empty means keeporg.log beside config.json + notifications desktop notifications (not shown on Windows yet) + username, token written by signing in + +Without the tray +---------------- keeporg.exe login -server https://keeporg.example.com -user you keeporg.exe run -`login` stores its configuration in -%AppData%\keeporg\config.json and mirrors your databases into -%UserProfile%\KeepOrg by default. Pass -dir to choose another directory. - -Open the .kdbx files in that directory with KeePass, KeePassXC or any -compatible application. Every save is pushed to the server, and changes made -by other people are applied to the files. - -Keeping it running ------------------- -Import keeporg-task.xml into Task Scheduler to start the agent at logon: +keeporg-task.xml starts `keeporg.exe run` at logon through Task Scheduler, +for a machine where nobody uses the tray: schtasks /create /tn KeepOrg /xml keeporg-task.xml +Only one agent synchronizes a directory at a time; a second one reports +that the first is running and stops. + Conflicts --------- If someone else saved first, your save is kept beside the file as diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh index 2d19f95..515b600 100755 --- a/scripts/build-deb.sh +++ b/scripts/build-deb.sh @@ -16,10 +16,17 @@ case $kind in client) pkg=keeporg binary=keeporg - mkdir -p "$stage/usr/bin" "$stage/usr/lib/systemd/user" + mkdir -p "$stage/usr/bin" "$stage/usr/lib/systemd/user" \ + "$stage/usr/share/applications" "$stage/usr/share/icons" install -m 0755 "$root/build/linux-$arch/keeporg" "$stage/usr/bin/keeporg" install -m 0644 "$root/packaging/systemd/keeporg.service" \ "$stage/usr/lib/systemd/user/keeporg.service" + # The tray application: a menu entry, and the icon it and the tray use. + install -m 0644 "$root/packaging/linux/keeporg.desktop" \ + "$stage/usr/share/applications/keeporg.desktop" + cp -R "$root/build/icons/hicolor" "$stage/usr/share/icons/hicolor" + rm -f "$stage/usr/share/icons/hicolor/.stamp" + find "$stage/usr/share/icons" -type d -exec chmod 0755 {} + -o -type f -exec chmod 0644 {} + ;; server) pkg=keeporgd diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh index c79fd9a..b6af6bf 100755 --- a/scripts/build-dmg.sh +++ b/scripts/build-dmg.sh @@ -21,12 +21,12 @@ app=$stage/KeepOrg.app rm -rf "$stage" mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" +# The bundle runs the client itself: started with no command it is the +# menu-bar application, and the same binary serves `keeporg` in a terminal. install -m 0755 "$root/build/darwin-$arch/keeporg" "$app/Contents/MacOS/keeporg" sed -e "s/@VERSION@/$version/" -e "s/@APPID@/$appid/" \ "$root/packaging/macos/Info.plist.in" > "$app/Contents/Info.plist" -install -m 0755 "$root/packaging/macos/launcher.sh" "$app/Contents/MacOS/KeepOrg" -sed -e "s/@APPID@/$appid/" "$root/packaging/macos/launch-agent.plist.in" \ - > "$app/Contents/Resources/$appid.plist" +iconutil -c icns -o "$app/Contents/Resources/KeepOrg.icns" "$root/build/icons/KeepOrg.iconset" cp "$root/docs/HANDOVER.md" "$stage/Read me first.md" 2>/dev/null || true # The symlink every macOS DMG carries, so installing is one drag. diff --git a/tools/genicon/main.go b/tools/genicon/main.go new file mode 100644 index 0000000..0dbb092 --- /dev/null +++ b/tools/genicon/main.go @@ -0,0 +1,46 @@ +// Command genicon writes the application icon for packaging. +// +// genicon -hicolor /x/apps/keeporg.png, the Linux icon theme layout +// genicon -iconset /icon_x[@2x].png, the input iconutil turns into .icns +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + "cc.tiredsysadmin.keeporg/internal/icon" +) + +func main() { + hicolor := flag.String("hicolor", "", "write a hicolor icon theme tree under this directory") + iconset := flag.String("iconset", "", "write a macOS .iconset directory") + flag.Parse() + if *hicolor == "" && *iconset == "" { + flag.Usage() + os.Exit(2) + } + if *hicolor != "" { + for _, n := range []int{16, 32, 48, 64, 128, 256, 512} { + write(filepath.Join(*hicolor, fmt.Sprintf("%dx%d", n, n), "apps", "keeporg.png"), n) + } + } + if *iconset != "" { + for _, n := range []int{16, 32, 128, 256, 512} { + write(filepath.Join(*iconset, fmt.Sprintf("icon_%dx%d.png", n, n)), n) + write(filepath.Join(*iconset, fmt.Sprintf("icon_%dx%d@2x.png", n, n)), 2*n) + } + } +} + +func write(path string, size int) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + fmt.Fprintln(os.Stderr, "genicon:", err) + os.Exit(1) + } + if err := os.WriteFile(path, icon.PNG(icon.App(size)), 0o644); err != nil { + fmt.Fprintln(os.Stderr, "genicon:", err) + os.Exit(1) + } +} diff --git a/versions.mk b/versions.mk index c6ccc1f..b229659 100644 --- a/versions.mk +++ b/versions.mk @@ -1,10 +1,10 @@ # Single source of truth for all platform versions (semver). # Bump with: make bump PLATFORM= PART= VERSION_SERVER := 0.7.0 -VERSION_LINUX := 0.4.0 -VERSION_MACOS := 0.4.0 -VERSION_WINDOWS := 0.4.0 -VERSION_BSD := 0.4.0 +VERSION_LINUX := 0.5.0 +VERSION_MACOS := 0.5.0 +VERSION_WINDOWS := 0.5.0 +VERSION_BSD := 0.5.0 VERSION_ANDROID := 0.2.0 # Monotonic Android versionCode, never decreases. ANDROID_VERSION_CODE := 2