Turn the desktop client into a system tray application
Started without a command, keeporg now shows a padlock in the system tray with a menu like midi-webhook's: a status line, the mirror folder, each database with its state, Sync Now, browser sign-in and sign-out, the web interface, the configuration, the log, Start at Login, About and Quit. The commands are unchanged. config.json now carries every client setting (device_label, request_timeout_seconds, ca_file, log_file, notifications), is written in full on first start and is reloaded by the tray when saved. A lock in the mirror directory admits one agent at a time, since the tray, `keeporg run` and a service unit could otherwise race each other. The tray uses fyne.io/systray: pure Go on Linux, BSD and Windows, cgo on macOS only. The server keeps no third-party dependencies. Desktop versions go to 0.5.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d314188247
commit
ead82dc777
@@ -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
|
||||
|
||||
@@ -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 <group-id>
|
||||
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
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>%s</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>%s</string>
|
||||
<string>tray</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>LimitLoadToSessionType</key>
|
||||
<string>Aqua</string>
|
||||
<key>ProcessType</key>
|
||||
<string>Interactive</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`, AppID, exe)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(plist), 0o644)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+42
-23
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+30
-6
@@ -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 <command> -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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`")
|
||||
}
|
||||
+123
-13
@@ -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 (`<AppID>.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.<os>.<app>`:
|
||||
`.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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
+133
-11
@@ -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 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:])
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
+41
-10
@@ -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() {
|
||||
// Kick asks a running mirror to reconcile now instead of at the next rescan.
|
||||
func (m *Mirror) Kick() {
|
||||
select {
|
||||
case trigger <- struct{}{}:
|
||||
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()
|
||||
|
||||
@@ -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()...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <github@reactorcoremeltdown.anonaddy.com> Mon, 02 Nov 2026 11:22:07 +0100
|
||||
|
||||
keeporg (0.4.0) unstable; urgency=medium
|
||||
|
||||
* keeporg db -delete now asks for your account password
|
||||
|
||||
@@ -3,6 +3,8 @@ Version: @VERSION@
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Architecture: @ARCH@
|
||||
Recommends: libnotify-bin
|
||||
Suggests: keepassxc, zenity | kdialog
|
||||
Maintainer: tiredsysadmin <rcmd@garage.rcmd.space>
|
||||
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`.
|
||||
|
||||
@@ -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
|
||||
@@ -15,10 +15,12 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>keeporg</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>KeepOrg</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>11.0</string>
|
||||
<!-- The agent has no window: it syncs in the background. -->
|
||||
<!-- A menu-bar application: no Dock icon, no main window. -->
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Command genicon writes the application icon for packaging.
|
||||
//
|
||||
// genicon -hicolor <dir> <dir>/<n>x<n>/apps/keeporg.png, the Linux icon theme layout
|
||||
// genicon -iconset <dir> <dir>/icon_<n>x<n>[@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)
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
# Single source of truth for all platform versions (semver).
|
||||
# Bump with: make bump PLATFORM=<server|linux|macos|windows|bsd|android> PART=<major|minor|patch>
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user