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 windows,$(1)),$(VERSION_WINDOWS),\
|
||||||
$(if $(filter freebsd openbsd netbsd,$(1)),$(VERSION_BSD),0.0.0)))))
|
$(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)))
|
os_of = $(word 1,$(subst -, ,$(1)))
|
||||||
arch_of = $(word 2,$(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
|
server: $(HOST_SERVER) ## build the server for this host
|
||||||
client: $(HOST_CLIENT) ## build the desktop client 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)
|
$(BUILD)/%/keeporg: $(GO_SRC)
|
||||||
@mkdir -p $(dir $@)
|
@mkdir -p $(dir $@)
|
||||||
GOOS=$(call os_of,$*) GOARCH=$(call arch_of,$*) CGO_ENABLED=0 \
|
GOOS=$(call os_of,$*) GOARCH=$(call arch_of,$*) CGO_ENABLED=0 \
|
||||||
$(GO) build $(GOFLAGS) \
|
$(GO) build $(GOFLAGS) -ldflags "$(call client_ldflags,$(call os_of,$*))" \
|
||||||
-ldflags "-s -w -X main.Version=$(call version_for,$(call os_of,$*))" \
|
|
||||||
-o $@ ./cmd/keeporg
|
-o $@ ./cmd/keeporg
|
||||||
|
|
||||||
$(BUILD)/%/keeporgd: $(GO_SRC)
|
$(BUILD)/%/keeporgd: $(GO_SRC)
|
||||||
@@ -77,19 +97,39 @@ $(BUILD)/%/keeporgd: $(GO_SRC)
|
|||||||
-ldflags "-s -w -X main.Version=$(VERSION_SERVER)" \
|
-ldflags "-s -w -X main.Version=$(VERSION_SERVER)" \
|
||||||
-o $@ ./cmd/keeporgd
|
-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)
|
$(BUILD)/windows-%/keeporg.exe: $(GO_SRC)
|
||||||
@mkdir -p $(dir $@)
|
@mkdir -p $(dir $@)
|
||||||
GOOS=windows GOARCH=$* CGO_ENABLED=0 \
|
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
|
-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
|
.PHONY: cross
|
||||||
cross: ## build every desktop and server binary
|
cross: ## build every desktop and server binary
|
||||||
cross: $(BUILD)/linux-amd64/keeporg $(BUILD)/linux-arm64/keeporg \
|
cross: $(BUILD)/linux-amd64/keeporg $(BUILD)/linux-arm64/keeporg \
|
||||||
$(BUILD)/linux-amd64/keeporgd $(BUILD)/linux-arm64/keeporgd \
|
$(BUILD)/linux-amd64/keeporgd $(BUILD)/linux-arm64/keeporgd \
|
||||||
$(BUILD)/darwin-amd64/keeporg $(BUILD)/darwin-arm64/keeporg \
|
$(BUILD)/darwin-amd64/keeporg $(BUILD)/darwin-arm64/keeporg \
|
||||||
$(BUILD)/freebsd-amd64/keeporg $(BUILD)/freebsd-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
|
# ------------------------------------------------------------------ packaging
|
||||||
|
|
||||||
@@ -103,7 +143,8 @@ deb-client: $(DEB_CLIENT) ## build the client .deb
|
|||||||
deb-server: $(DEB_SERVER) ## build the server .deb
|
deb-server: $(DEB_SERVER) ## build the server .deb
|
||||||
|
|
||||||
$(DEB_CLIENT): $(BUILD)/linux-$(DEB_ARCH)/keeporg packaging/deb/client/control.in \
|
$(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) $@
|
scripts/build-deb.sh client $(VERSION_LINUX) $(DEB_ARCH) $@
|
||||||
|
|
||||||
$(DEB_SERVER): $(BUILD)/linux-$(DEB_ARCH)/keeporgd packaging/deb/server/control.in \
|
$(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
|
.PHONY: dmg
|
||||||
dmg: $(DMG) ## build the macOS DMG with an /Applications symlink (macOS only)
|
dmg: $(DMG) ## build the macOS DMG with an /Applications symlink (macOS only)
|
||||||
|
|
||||||
$(DMG): $(BUILD)/darwin-$(DMG_ARCH)/keeporg packaging/macos/Info.plist.in \
|
$(DMG): $(BUILD)/darwin-$(DMG_ARCH)/keeporg packaging/macos/Info.plist.in $(ICONSET)
|
||||||
packaging/macos/launcher.sh packaging/macos/launch-agent.plist.in
|
|
||||||
scripts/build-dmg.sh $(VERSION_MACOS) $(DMG_ARCH) $(APPID_MACOS) $@
|
scripts/build-dmg.sh $(VERSION_MACOS) $(DMG_ARCH) $(APPID_MACOS) $@
|
||||||
|
|
||||||
ZIP := $(DIST)/keeporg-$(VERSION_WINDOWS)-windows-amd64.zip
|
ZIP := $(DIST)/keeporg-$(VERSION_WINDOWS)-windows-amd64.zip
|
||||||
@@ -125,10 +165,11 @@ ZIP := $(DIST)/keeporg-$(VERSION_WINDOWS)-windows-amd64.zip
|
|||||||
.PHONY: windows
|
.PHONY: windows
|
||||||
windows: $(ZIP) ## build the Windows zip
|
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)
|
@mkdir -p $(DIST)
|
||||||
rm -rf $(BUILD)/windows-stage && mkdir -p $(BUILD)/windows-stage
|
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/README.txt $(BUILD)/windows-stage/
|
||||||
cp packaging/windows/keeporg-task.xml $(BUILD)/windows-stage/
|
cp packaging/windows/keeporg-task.xml $(BUILD)/windows-stage/
|
||||||
cd $(BUILD)/windows-stage && zip -q -r ../../$@ .
|
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
|
$(HOST_SERVER) -addr 127.0.0.1:8765 -data .devdata -url http://127.0.0.1:8765
|
||||||
|
|
||||||
.PHONY: run-client
|
.PHONY: run-client
|
||||||
run-client: $(HOST_CLIENT) ## run the sync agent with the local configuration
|
run-client: $(HOST_CLIENT) ## run the tray application with the local configuration
|
||||||
$(HOST_CLIENT) run
|
$(HOST_CLIENT) tray
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
cat config/variables/main.json | tpl -f config/nginx/$(APP_NAME).conf.tmpl > /etc/nginx/sites-available/$(APP_NAME).conf
|
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,
|
with a tree of user groups, accepts registrations by invite link or OIDC,
|
||||||
merges concurrent edits, and pushes updates to connected clients. Its web
|
merges concurrent edits, and pushes updates to connected clients. Its web
|
||||||
page at `/web` is for creating a vault and setting its password.
|
page at `/web` is for creating a vault and setting its password.
|
||||||
- **Desktop client** (`keeporg`, Linux, macOS, Windows, BSD): mirrors every
|
- **Desktop client** (`keeporg`, Linux, macOS, Windows, BSD): a system tray
|
||||||
assigned database into one directory as a real file, pushes each save and
|
application that mirrors every assigned database into one directory as a
|
||||||
applies what others push.
|
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
|
- **Android client**: the same, published to every file picker as a storage
|
||||||
provider.
|
provider.
|
||||||
|
|
||||||
No third-party dependencies, no cgo, no FUSE. The KDBX 4 codec, including
|
No FUSE, and the server has no third-party dependencies and no cgo. The
|
||||||
ChaCha20, BLAKE2b and Argon2, is part of the repository and is tested against
|
KDBX 4 codec, including ChaCha20, BLAKE2b and Argon2, is part of the
|
||||||
both the relevant RFCs and a real KeePassXC.
|
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
|
## 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 login -server http://127.0.0.1:8765 -user admin
|
||||||
build/linux-arm64/keeporg groups
|
build/linux-arm64/keeporg groups
|
||||||
build/linux-arm64/keeporg vault -create "Team Vault" -group <group-id>
|
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
|
`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
|
password you just set. Every save is pushed, and a save that races with a
|
||||||
colleague's is merged rather than refused.
|
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.
|
Or open `http://127.0.0.1:8765/web` and create the vault from the browser.
|
||||||
|
|
||||||
## Packaging
|
## 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 deb # Debian packages for the client and the server
|
||||||
make docker # server container image, scratch-based, about 9 MB
|
make docker # server container image, scratch-based, about 9 MB
|
||||||
make dmg # macOS disk image with an /Applications symlink (on macOS)
|
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 android # unsigned release APK (needs the Android SDK)
|
||||||
make help # every target
|
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"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
@@ -31,7 +32,8 @@ func session(path string) (*client.Config, *client.API, error) {
|
|||||||
if cfg.Token == "" {
|
if cfg.Token == "" {
|
||||||
return nil, nil, client.ErrNoConfig
|
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 {
|
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)")
|
user := fs.String("user", "", "username (omit with -browser)")
|
||||||
dir := fs.String("dir", "", "mirror directory (default "+client.DefaultMirrorDir()+")")
|
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")
|
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)
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
cfg, err := client.LoadConfig(*path)
|
// A setting that fails validation may be the one this login is about to
|
||||||
if err != nil && !errors.Is(err, client.ErrNoConfig) {
|
// fix, so only an unreadable file stops here; Validate runs below.
|
||||||
return err
|
cfg, err := client.EnsureConfig(*path)
|
||||||
}
|
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
cfg = &client.Config{MirrorDir: client.DefaultMirrorDir(), PollSeconds: 2}
|
return err
|
||||||
}
|
}
|
||||||
if *server != "" {
|
if *server != "" {
|
||||||
cfg.ServerURL = strings.TrimRight(*server, "/")
|
cfg.ServerURL = strings.TrimRight(*server, "/")
|
||||||
@@ -60,20 +61,27 @@ func cmdLogin(ctx context.Context, args []string) error {
|
|||||||
if *dir != "" {
|
if *dir != "" {
|
||||||
cfg.MirrorDir = *dir
|
cfg.MirrorDir = *dir
|
||||||
}
|
}
|
||||||
if cfg.MirrorDir == "" {
|
if *label != "" {
|
||||||
cfg.MirrorDir = client.DefaultMirrorDir()
|
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
|
var sess *client.Session
|
||||||
switch {
|
switch {
|
||||||
case *browser != "":
|
case *browser != "":
|
||||||
sess, err = browserLogin(ctx, api, *label)
|
sess, err = browserLogin(ctx, api, cfg.DeviceLabel)
|
||||||
default:
|
default:
|
||||||
if *user == "" {
|
if *user == "" {
|
||||||
return errors.New("-user is required, or use -browser=yes")
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -86,7 +94,7 @@ func cmdLogin(ctx context.Context, args []string) error {
|
|||||||
if !sess.User.TOTPEnabled {
|
if !sess.User.TOTPEnabled {
|
||||||
fmt.Println("tip: run `keeporg totp` to add a second factor")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,13 +177,20 @@ func cmdRun(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
lg := log.New(out, "", log.LstdFlags)
|
||||||
if *quiet {
|
if *quiet {
|
||||||
lg = log.New(io.Discard, "", 0)
|
lg = log.New(io.Discard, "", 0)
|
||||||
}
|
}
|
||||||
mir, err := client.NewMirror(api, cfg.MirrorDir, lg,
|
mir, err := client.NewMirror(api, cfg.MirrorDir, lg, cfg.Poll())
|
||||||
time.Duration(cfg.PollSeconds)*time.Second)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -196,11 +211,15 @@ func cmdSync(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
mir, err := client.NewMirror(api, cfg.MirrorDir, log.New(os.Stderr, "", 0),
|
mir, err := client.NewMirror(api, cfg.MirrorDir, log.New(os.Stderr, "", 0), cfg.Poll())
|
||||||
time.Duration(cfg.PollSeconds)*time.Second)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err := mir.SyncAll(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -719,10 +738,10 @@ func openBrowser(url string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func hostLabel() string {
|
// openLog opens a log file for appending, creating its directory.
|
||||||
h, err := os.Hostname()
|
func openLog(path string) (*os.File, error) {
|
||||||
if err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
return "keeporg client"
|
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
|
// 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.
|
// files, pushes each save to the server and applies what other clients push.
|
||||||
// Point KeePassXC, KeePass2 or any compatible application at those files.
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,14 +14,19 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
|
||||||
"cc.tiredsysadmin.keeporg/internal/client"
|
"cc.tiredsysadmin.keeporg/internal/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version is stamped at build time from versions.mk.
|
// Version and AppID are stamped at build time from versions.mk.
|
||||||
var Version = "dev"
|
var (
|
||||||
|
Version = "dev"
|
||||||
|
AppID = "cc.tiredsysadmin.keeporg"
|
||||||
|
)
|
||||||
|
|
||||||
type command struct {
|
type command struct {
|
||||||
name string
|
name string
|
||||||
@@ -28,6 +36,7 @@ type command struct {
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cmds := []command{
|
cmds := []command{
|
||||||
|
{"tray", "run as a system tray application (the default)", cmdTray},
|
||||||
{"login", "sign in to a server and choose the mirror directory", cmdLogin},
|
{"login", "sign in to a server and choose the mirror directory", cmdLogin},
|
||||||
{"logout", "revoke this device's session", cmdLogout},
|
{"logout", "revoke this device's session", cmdLogout},
|
||||||
{"run", "keep the mirror in sync until stopped", cmdRun},
|
{"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)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
if len(os.Args) < 2 {
|
// A double-click, a launcher or a login item starts the tray. Old macOS
|
||||||
usage(cmds)
|
// releases pass a process serial number to an application bundle.
|
||||||
os.Exit(2)
|
if len(os.Args) < 2 || strings.HasPrefix(os.Args[1], "-psn_") {
|
||||||
|
os.Args = append(os.Args[:1], "tray")
|
||||||
}
|
}
|
||||||
name := os.Args[1]
|
name := os.Args[1]
|
||||||
if name == "version" || name == "-version" || name == "--version" {
|
switch name {
|
||||||
|
case "version", "-version", "--version":
|
||||||
fmt.Println("keeporg", Version)
|
fmt.Println("keeporg", Version)
|
||||||
return
|
return
|
||||||
|
case "help", "-h", "-help", "--help":
|
||||||
|
usage(cmds)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
for _, c := range cmds {
|
for _, c := range cmds {
|
||||||
if c.name != name {
|
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"+
|
fmt.Fprintf(os.Stderr, "\nRun `keeporg <command> -h` for a command's flags.\n"+
|
||||||
"Configuration lives in %s\n", client.DefaultConfigPath())
|
"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/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/store/ domain model, JSON metadata store, ACL resolution
|
||||||
internal/blob/ versioned KDBX payload storage on disk
|
internal/blob/ versioned KDBX payload storage on disk
|
||||||
internal/kdbx/ KDBX 4 codec and the merge engine, with its own crypto
|
internal/kdbx/ KDBX 4 codec and the merge engine, with its own crypto
|
||||||
internal/server/ HTTP surface: auth, OIDC, sync, admin, SSE hub, web,
|
internal/server/ HTTP surface: auth, OIDC, sync, admin, SSE hub, web,
|
||||||
web administration, page templates
|
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/secret/ password verifiers, tokens, identifiers
|
||||||
internal/totp/ RFC 6238 second factor
|
internal/totp/ RFC 6238 second factor
|
||||||
android/ Android client: DocumentsProvider, sync service, UI
|
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,
|
packaging/ deb, macOS bundle and DMG, Windows zip, systemd units,
|
||||||
container compose example
|
container compose example
|
||||||
scripts/ package builders, version bump, changelog
|
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
|
versions.mk the one place every platform's version lives
|
||||||
Makefile the only entry point for building and publishing
|
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`)
|
### Desktop client (`keeporg`)
|
||||||
|
|
||||||
`keeporg run` keeps a mirror directory (default `~/KeepOrg`) in step with the
|
One binary, two faces. Started without a command it is a system tray
|
||||||
server:
|
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;
|
- a rescan every two seconds notices that a KeePass application saved a file;
|
||||||
- the server's event stream delivers other people's pushes immediately;
|
- 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
|
`internal/client/decide.go`. Everything else in the mirror applies the action
|
||||||
it returns. To change behaviour, change that table and its test.
|
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
|
### Android client
|
||||||
|
|
||||||
`KeepOrgProvider` is a `DocumentsProvider`, so the mirrored files appear in
|
`KeepOrgProvider` is a `DocumentsProvider`, so the mirrored files appear in
|
||||||
@@ -421,13 +485,26 @@ be left behind.
|
|||||||
|
|
||||||
## Decisions worth knowing
|
## Decisions worth knowing
|
||||||
|
|
||||||
**No third-party Go dependencies, no cgo.** The whole server and desktop
|
**No third-party Go dependencies in the server, no cgo outside the macOS
|
||||||
client build with `CGO_ENABLED=0` from the standard library: SSE instead of a
|
tray.** The server builds with `CGO_ENABLED=0` from the standard library
|
||||||
WebSocket library, `crypto/pbkdf2` (new in Go 1.24) for account passwords,
|
alone: SSE instead of a WebSocket library, `crypto/pbkdf2` (new in Go 1.24)
|
||||||
hand-written ChaCha20, BLAKE2b and Argon2 for the KDBX format, 60 lines of
|
for account passwords, hand-written ChaCha20, BLAKE2b and Argon2 for the
|
||||||
HMAC for TOTP, `html/template` for the pages.
|
KDBX format, 60 lines of HMAC for TOTP, `html/template` for the pages. There
|
||||||
Cross-compiling to five platforms is then one `go build` each, and there is
|
is no dependency tree to audit in the program that holds the vault keys;
|
||||||
no dependency tree to audit for a program that guards passwords.
|
`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,
|
**A JSON document instead of SQLite.** The metadata is hundreds of records,
|
||||||
never thousands: users, groups, grants. One writer with atomic rename gives
|
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
|
`.windows.keeporg`, `.bsd.keeporg`, and `.linux.keeporgd` for the server
|
||||||
package.
|
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:
|
manual, never automatic:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -661,7 +750,18 @@ What the suite actually covers:
|
|||||||
- `internal/client` for the decision table, and for the mirror driven against
|
- `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,
|
a real in-process server: adopting a local file, following a remote push,
|
||||||
server-wins, client-wins, manual, read-only restore, server-side rename,
|
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
|
The Android module is checked by `make android-lint`, which is kept at zero
|
||||||
findings, and `make android` builds the unsigned release APK. See
|
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
|
## 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
|
- **One server node.** The JSON store is single-writer by design. Horizontal
|
||||||
scale means replacing `internal/store`.
|
scale means replacing `internal/store`.
|
||||||
- **Merging requires the server to hold the vault key.** That is inherent, not
|
- **Merging requires the server to hold the vault key.** That is inherent, not
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
module cc.tiredsysadmin.keeporg
|
module cc.tiredsysadmin.keeporg
|
||||||
|
|
||||||
go 1.24
|
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=
|
||||||
+134
-12
@@ -1,22 +1,48 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is what a client remembers between runs. The session token lives
|
// Config is what a client remembers between runs, and every setting a user
|
||||||
// here; the KDBX master passwords never do, because the client never opens a
|
// can change. The session token lives here; the KDBX master passwords never
|
||||||
// database, it only moves bytes.
|
// 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 {
|
type Config struct {
|
||||||
|
// ServerURL is the server's base URL, as a browser sees it.
|
||||||
ServerURL string `json:"server_url"`
|
ServerURL string `json:"server_url"`
|
||||||
Token string `json:"token"`
|
// Username and Token are written by signing in, not by hand.
|
||||||
Username string `json:"username"`
|
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"`
|
MirrorDir string `json:"mirror_dir"`
|
||||||
// PollSeconds is how often the mirror is rescanned for local saves.
|
// PollSeconds is how often the mirror is rescanned for local saves.
|
||||||
PollSeconds int `json:"poll_seconds"`
|
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.
|
// ErrNoConfig means the client has not been logged in yet.
|
||||||
@@ -40,7 +66,24 @@ func DefaultMirrorDir() string {
|
|||||||
return filepath.Join(home, "KeepOrg")
|
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) {
|
func LoadConfig(path string) (*Config, error) {
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -49,19 +92,85 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var c Config
|
c := DefaultConfig()
|
||||||
if err := json.Unmarshal(b, &c); err != nil {
|
if err := json.Unmarshal(b, c); err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
def := DefaultConfig()
|
||||||
if c.MirrorDir == "" {
|
if c.MirrorDir == "" {
|
||||||
c.MirrorDir = DefaultMirrorDir()
|
c.MirrorDir = def.MirrorDir
|
||||||
|
}
|
||||||
|
if c.DeviceLabel == "" {
|
||||||
|
c.DeviceLabel = def.DeviceLabel
|
||||||
}
|
}
|
||||||
if c.PollSeconds <= 0 {
|
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
|
// Save writes the configuration with owner-only permissions, since it holds
|
||||||
// a bearer token.
|
// a bearer token.
|
||||||
func (c *Config) Save(path string) error {
|
func (c *Config) Save(path string) error {
|
||||||
@@ -78,3 +187,16 @@ func (c *Config) Save(path string) error {
|
|||||||
}
|
}
|
||||||
return os.Rename(tmp, path)
|
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)
|
||||||
+43
-12
@@ -57,9 +57,17 @@ type Mirror struct {
|
|||||||
log *log.Logger
|
log *log.Logger
|
||||||
poll time.Duration
|
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
|
mu sync.Mutex
|
||||||
st map[string]*fileState
|
st map[string]*fileState
|
||||||
status map[string]*Status
|
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.
|
// 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 {
|
if poll <= 0 {
|
||||||
poll = 2 * time.Second
|
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{}}
|
st: map[string]*fileState{}, status: map[string]*Status{}}
|
||||||
b, err := os.ReadFile(filepath.Join(dir, stateFile))
|
b, err := os.ReadFile(filepath.Join(dir, stateFile))
|
||||||
switch {
|
switch {
|
||||||
@@ -88,18 +96,26 @@ func NewMirror(api *API, dir string, lg *log.Logger, poll time.Duration) (*Mirro
|
|||||||
// Dir is the mirror directory.
|
// Dir is the mirror directory.
|
||||||
func (m *Mirror) Dir() string { return m.dir }
|
func (m *Mirror) Dir() string { return m.dir }
|
||||||
|
|
||||||
// Run reconciles until ctx is done, driven by server push events and by a
|
// Kick asks a running mirror to reconcile now instead of at the next rescan.
|
||||||
// local rescan for saves made by a KeePass application.
|
func (m *Mirror) Kick() {
|
||||||
func (m *Mirror) Run(ctx context.Context) error {
|
select {
|
||||||
trigger := make(chan struct{}, 1)
|
case m.kick <- struct{}{}:
|
||||||
wake := func() {
|
default:
|
||||||
select {
|
|
||||||
case trigger <- 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 {
|
if err := m.SyncAll(ctx); err != nil {
|
||||||
m.log.Printf("sync: %v", err)
|
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 {
|
if err := m.SyncAll(ctx); err != nil {
|
||||||
m.log.Printf("sync: %v", err)
|
m.log.Printf("sync: %v", err)
|
||||||
}
|
}
|
||||||
case <-trigger:
|
case <-m.kick:
|
||||||
// Let a burst of events settle before reconciling.
|
// Let a burst of events settle before reconciling.
|
||||||
time.Sleep(150 * time.Millisecond)
|
time.Sleep(150 * time.Millisecond)
|
||||||
if err := m.SyncAll(ctx); err != nil {
|
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 {
|
func (m *Mirror) SyncAll(ctx context.Context) error {
|
||||||
dbs, err := m.api.Databases(ctx)
|
dbs, err := m.api.Databases(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.lastSync, m.lastErr = time.Now(), err
|
||||||
|
m.mu.Unlock()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
defer func() { m.lastSync = time.Now() }()
|
||||||
|
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
var firstErr error
|
var firstErr error
|
||||||
@@ -184,9 +204,20 @@ func (m *Mirror) SyncAll(ctx context.Context) error {
|
|||||||
if err := m.saveState(); err != nil && firstErr == nil {
|
if err := m.saveState(); err != nil && firstErr == nil {
|
||||||
firstErr = err
|
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
|
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.
|
// Status reports the current state of each mirrored database.
|
||||||
func (m *Mirror) Status() []Status {
|
func (m *Mirror) Status() []Status {
|
||||||
m.mu.Lock()
|
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 (0.4.0) unstable; urgency=medium
|
||||||
|
|
||||||
* keeporg db -delete now asks for your account password
|
* keeporg db -delete now asks for your account password
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ Version: @VERSION@
|
|||||||
Section: utils
|
Section: utils
|
||||||
Priority: optional
|
Priority: optional
|
||||||
Architecture: @ARCH@
|
Architecture: @ARCH@
|
||||||
|
Recommends: libnotify-bin
|
||||||
|
Suggests: keepassxc, zenity | kdialog
|
||||||
Maintainer: tiredsysadmin <rcmd@garage.rcmd.space>
|
Maintainer: tiredsysadmin <rcmd@garage.rcmd.space>
|
||||||
Homepage: https://garage.rcmd.space/keeporg
|
Homepage: https://garage.rcmd.space/keeporg
|
||||||
Description: KeePass database synchronization client
|
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
|
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.
|
locally. The client never opens a database and never sees a master password.
|
||||||
.
|
.
|
||||||
A systemd user unit is included: enable it with
|
KeepOrg starts as a system tray application from the applications menu; its
|
||||||
`systemctl --user enable --now keeporg`.
|
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>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>keeporg</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
<string>KeepOrg</string>
|
<string>KeepOrg</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>11.0</string>
|
<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>
|
<key>LSUIElement</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSHighResolutionCapable</key>
|
<key>NSHighResolutionCapable</key>
|
||||||
|
|||||||
@@ -1,27 +1,53 @@
|
|||||||
KeepOrg for Windows
|
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
|
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 login -server https://keeporg.example.com -user you
|
||||||
keeporg.exe run
|
keeporg.exe run
|
||||||
|
|
||||||
`login` stores its configuration in
|
keeporg-task.xml starts `keeporg.exe run` at logon through Task Scheduler,
|
||||||
%AppData%\keeporg\config.json and mirrors your databases into
|
for a machine where nobody uses the tray:
|
||||||
%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:
|
|
||||||
|
|
||||||
schtasks /create /tn KeepOrg /xml keeporg-task.xml
|
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
|
Conflicts
|
||||||
---------
|
---------
|
||||||
If someone else saved first, your save is kept beside the file as
|
If someone else saved first, your save is kept beside the file as
|
||||||
|
|||||||
@@ -16,10 +16,17 @@ case $kind in
|
|||||||
client)
|
client)
|
||||||
pkg=keeporg
|
pkg=keeporg
|
||||||
binary=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 0755 "$root/build/linux-$arch/keeporg" "$stage/usr/bin/keeporg"
|
||||||
install -m 0644 "$root/packaging/systemd/keeporg.service" \
|
install -m 0644 "$root/packaging/systemd/keeporg.service" \
|
||||||
"$stage/usr/lib/systemd/user/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)
|
server)
|
||||||
pkg=keeporgd
|
pkg=keeporgd
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ app=$stage/KeepOrg.app
|
|||||||
rm -rf "$stage"
|
rm -rf "$stage"
|
||||||
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
|
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"
|
install -m 0755 "$root/build/darwin-$arch/keeporg" "$app/Contents/MacOS/keeporg"
|
||||||
sed -e "s/@VERSION@/$version/" -e "s/@APPID@/$appid/" \
|
sed -e "s/@VERSION@/$version/" -e "s/@APPID@/$appid/" \
|
||||||
"$root/packaging/macos/Info.plist.in" > "$app/Contents/Info.plist"
|
"$root/packaging/macos/Info.plist.in" > "$app/Contents/Info.plist"
|
||||||
install -m 0755 "$root/packaging/macos/launcher.sh" "$app/Contents/MacOS/KeepOrg"
|
iconutil -c icns -o "$app/Contents/Resources/KeepOrg.icns" "$root/build/icons/KeepOrg.iconset"
|
||||||
sed -e "s/@APPID@/$appid/" "$root/packaging/macos/launch-agent.plist.in" \
|
|
||||||
> "$app/Contents/Resources/$appid.plist"
|
|
||||||
cp "$root/docs/HANDOVER.md" "$stage/Read me first.md" 2>/dev/null || true
|
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.
|
# 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).
|
# Single source of truth for all platform versions (semver).
|
||||||
# Bump with: make bump PLATFORM=<server|linux|macos|windows|bsd|android> PART=<major|minor|patch>
|
# Bump with: make bump PLATFORM=<server|linux|macos|windows|bsd|android> PART=<major|minor|patch>
|
||||||
VERSION_SERVER := 0.7.0
|
VERSION_SERVER := 0.7.0
|
||||||
VERSION_LINUX := 0.4.0
|
VERSION_LINUX := 0.5.0
|
||||||
VERSION_MACOS := 0.4.0
|
VERSION_MACOS := 0.5.0
|
||||||
VERSION_WINDOWS := 0.4.0
|
VERSION_WINDOWS := 0.5.0
|
||||||
VERSION_BSD := 0.4.0
|
VERSION_BSD := 0.5.0
|
||||||
VERSION_ANDROID := 0.2.0
|
VERSION_ANDROID := 0.2.0
|
||||||
# Monotonic Android versionCode, never decreases.
|
# Monotonic Android versionCode, never decreases.
|
||||||
ANDROID_VERSION_CODE := 2
|
ANDROID_VERSION_CODE := 2
|
||||||
|
|||||||
Reference in New Issue
Block a user