Add Android client, packaging, Makefile entrypoint and handover docs
Why: the set has to be installable and publishable, not just buildable, and the next person needs the reasoning, not only the code. What: - android/: Kotlin client with no third-party dependencies. A DocumentsProvider publishes the mirrored databases to every file picker, so KeePassDX opens them as ordinary documents; closing a file it wrote triggers a sync. A foreground service holds the event stream open. Mirror.decide transcribes the Go merge table, so both platforms resolve a conflict identically. - Makefile as the single entrypoint, with file targets so only what changed is rebuilt: host and cross builds, both .deb packages, the macOS DMG with its /Applications symlink, the Windows zip, the APK under its F-Droid publishing name, and manual-only android-publish. - versions.mk holds one version per platform plus the Android versionCode; `make bump` and `make changelog` move them. Debian changelog is tracked. - systemd units (hardened system unit for the server, user unit for the agent), macOS bundle and launch agent, Windows Task Scheduler job. - docs/HANDOVER.md on architecture and every decision that shaped it, docs/PROTOCOL.md for the API, README for the quick start. Proved: make check and make test-race both pass; built both .deb packages and inspected their contents, built the Windows zip, cross-compiled all five desktop targets, and exercised make bump. The Android module is written but not compiled: no JDK or Android SDK in this environment, which HANDOVER.md states plainly.
This commit is contained in:
@@ -0,0 +1,188 @@
|
|||||||
|
# KeepOrg: build and publish entrypoint.
|
||||||
|
#
|
||||||
|
# Every target is a real file where it can be, so make rebuilds only what
|
||||||
|
# changed. Run `make help` for the list.
|
||||||
|
|
||||||
|
include versions.mk
|
||||||
|
|
||||||
|
GO := go
|
||||||
|
BUILD := build
|
||||||
|
DIST := dist
|
||||||
|
GOFLAGS := -trimpath
|
||||||
|
HOSTOS := $(shell $(GO) env GOOS)
|
||||||
|
HOSTARCH := $(shell $(GO) env GOARCH)
|
||||||
|
GO_SRC := $(shell find . -name '*.go' -not -path './$(DIST)/*' -not -path './$(BUILD)/*') go.mod
|
||||||
|
|
||||||
|
# The version stamped into a binary depends on the platform it is built for.
|
||||||
|
version_for = $(strip \
|
||||||
|
$(if $(filter linux,$(1)),$(VERSION_LINUX),\
|
||||||
|
$(if $(filter darwin,$(1)),$(VERSION_MACOS),\
|
||||||
|
$(if $(filter windows,$(1)),$(VERSION_WINDOWS),\
|
||||||
|
$(if $(filter freebsd openbsd netbsd,$(1)),$(VERSION_BSD),0.0.0)))))
|
||||||
|
|
||||||
|
os_of = $(word 1,$(subst -, ,$(1)))
|
||||||
|
arch_of = $(word 2,$(subst -, ,$(1)))
|
||||||
|
|
||||||
|
HOST_CLIENT := $(BUILD)/$(HOSTOS)-$(HOSTARCH)/keeporg
|
||||||
|
HOST_SERVER := $(BUILD)/$(HOSTOS)-$(HOSTARCH)/keeporgd
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- developing
|
||||||
|
|
||||||
|
help: ## show this help
|
||||||
|
@printf 'KeepOrg %s\n\n' "$(VERSION_LINUX)"
|
||||||
|
@grep -hE '^[a-zA-Z0-9_%/.-]+:.*?## ' $(MAKEFILE_LIST) \
|
||||||
|
| sort | awk 'BEGIN{FS=":.*?## "}{printf " \033[1m%-22s\033[0m %s\n", $$1, $$2}'
|
||||||
|
@printf '\nVersions: server %s linux %s macos %s windows %s bsd %s android %s (code %s)\n' \
|
||||||
|
"$(VERSION_SERVER)" "$(VERSION_LINUX)" "$(VERSION_MACOS)" "$(VERSION_WINDOWS)" \
|
||||||
|
"$(VERSION_BSD)" "$(VERSION_ANDROID)" "$(ANDROID_VERSION_CODE)"
|
||||||
|
|
||||||
|
fmt: ## format the Go sources
|
||||||
|
gofmt -w $(filter %.go,$(GO_SRC))
|
||||||
|
|
||||||
|
vet: ## run go vet
|
||||||
|
$(GO) vet ./...
|
||||||
|
|
||||||
|
test: ## run the whole test suite
|
||||||
|
$(GO) test ./...
|
||||||
|
|
||||||
|
test-race: ## run the test suite under the race detector
|
||||||
|
$(GO) test -race ./...
|
||||||
|
|
||||||
|
check: fmt vet test ## format, vet and test
|
||||||
|
|
||||||
|
clean: ## remove build and dist output
|
||||||
|
rm -rf $(BUILD) $(DIST)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ building
|
||||||
|
|
||||||
|
.PHONY: all server client
|
||||||
|
all: server client ## build the server and client for this host
|
||||||
|
server: $(HOST_SERVER) ## build the server for this host
|
||||||
|
client: $(HOST_CLIENT) ## build the desktop client for this host
|
||||||
|
|
||||||
|
$(BUILD)/%/keeporg: $(GO_SRC)
|
||||||
|
@mkdir -p $(dir $@)
|
||||||
|
GOOS=$(call os_of,$*) GOARCH=$(call arch_of,$*) CGO_ENABLED=0 \
|
||||||
|
$(GO) build $(GOFLAGS) \
|
||||||
|
-ldflags "-s -w -X main.Version=$(call version_for,$(call os_of,$*))" \
|
||||||
|
-o $@ ./cmd/keeporg
|
||||||
|
|
||||||
|
$(BUILD)/%/keeporgd: $(GO_SRC)
|
||||||
|
@mkdir -p $(dir $@)
|
||||||
|
GOOS=$(call os_of,$*) GOARCH=$(call arch_of,$*) CGO_ENABLED=0 \
|
||||||
|
$(GO) build $(GOFLAGS) \
|
||||||
|
-ldflags "-s -w -X main.Version=$(VERSION_SERVER)" \
|
||||||
|
-o $@ ./cmd/keeporgd
|
||||||
|
|
||||||
|
$(BUILD)/windows-%/keeporg.exe: $(GO_SRC)
|
||||||
|
@mkdir -p $(dir $@)
|
||||||
|
GOOS=windows GOARCH=$* CGO_ENABLED=0 \
|
||||||
|
$(GO) build $(GOFLAGS) -ldflags "-s -w -X main.Version=$(VERSION_WINDOWS)" \
|
||||||
|
-o $@ ./cmd/keeporg
|
||||||
|
|
||||||
|
.PHONY: cross
|
||||||
|
cross: ## build every desktop and server binary
|
||||||
|
cross: $(BUILD)/linux-amd64/keeporg $(BUILD)/linux-arm64/keeporg \
|
||||||
|
$(BUILD)/linux-amd64/keeporgd $(BUILD)/linux-arm64/keeporgd \
|
||||||
|
$(BUILD)/darwin-amd64/keeporg $(BUILD)/darwin-arm64/keeporg \
|
||||||
|
$(BUILD)/freebsd-amd64/keeporg $(BUILD)/freebsd-arm64/keeporg \
|
||||||
|
$(BUILD)/windows-amd64/keeporg.exe
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ packaging
|
||||||
|
|
||||||
|
DEB_ARCH ?= $(HOSTARCH)
|
||||||
|
DEB_CLIENT := $(DIST)/keeporg_$(VERSION_LINUX)_$(DEB_ARCH).deb
|
||||||
|
DEB_SERVER := $(DIST)/keeporgd_$(VERSION_SERVER)_$(DEB_ARCH).deb
|
||||||
|
|
||||||
|
.PHONY: deb deb-client deb-server
|
||||||
|
deb: deb-client deb-server ## build both Debian packages
|
||||||
|
deb-client: $(DEB_CLIENT) ## build the client .deb
|
||||||
|
deb-server: $(DEB_SERVER) ## build the server .deb
|
||||||
|
|
||||||
|
$(DEB_CLIENT): $(BUILD)/linux-$(DEB_ARCH)/keeporg packaging/deb/client/control.in \
|
||||||
|
packaging/deb/changelog packaging/systemd/keeporg.service
|
||||||
|
scripts/build-deb.sh client $(VERSION_LINUX) $(DEB_ARCH) $@
|
||||||
|
|
||||||
|
$(DEB_SERVER): $(BUILD)/linux-$(DEB_ARCH)/keeporgd packaging/deb/server/control.in \
|
||||||
|
packaging/deb/changelog packaging/systemd/keeporgd.service
|
||||||
|
scripts/build-deb.sh server $(VERSION_SERVER) $(DEB_ARCH) $@
|
||||||
|
|
||||||
|
DMG_ARCH ?= $(HOSTARCH)
|
||||||
|
DMG := $(DIST)/KeepOrg-$(VERSION_MACOS)-$(DMG_ARCH).dmg
|
||||||
|
|
||||||
|
.PHONY: dmg
|
||||||
|
dmg: $(DMG) ## build the macOS DMG with an /Applications symlink (macOS only)
|
||||||
|
|
||||||
|
$(DMG): $(BUILD)/darwin-$(DMG_ARCH)/keeporg packaging/macos/Info.plist.in \
|
||||||
|
packaging/macos/launcher.sh packaging/macos/launch-agent.plist.in
|
||||||
|
scripts/build-dmg.sh $(VERSION_MACOS) $(DMG_ARCH) $(APPID_MACOS) $@
|
||||||
|
|
||||||
|
ZIP := $(DIST)/keeporg-$(VERSION_WINDOWS)-windows-amd64.zip
|
||||||
|
|
||||||
|
.PHONY: windows
|
||||||
|
windows: $(ZIP) ## build the Windows zip
|
||||||
|
|
||||||
|
$(ZIP): $(BUILD)/windows-amd64/keeporg.exe packaging/windows/README.txt
|
||||||
|
@mkdir -p $(DIST)
|
||||||
|
rm -rf $(BUILD)/windows-stage && mkdir -p $(BUILD)/windows-stage
|
||||||
|
cp $(BUILD)/windows-amd64/keeporg.exe $(BUILD)/windows-stage/
|
||||||
|
cp packaging/windows/README.txt $(BUILD)/windows-stage/
|
||||||
|
cp packaging/windows/keeporg-task.xml $(BUILD)/windows-stage/
|
||||||
|
cd $(BUILD)/windows-stage && zip -q -r ../../$@ .
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- android
|
||||||
|
|
||||||
|
GRADLE ?= $(shell test -x android/gradlew && echo ./gradlew || echo gradle)
|
||||||
|
APK_BUILT := android/app/build/outputs/apk/release/app-release-unsigned.apk
|
||||||
|
APK := $(DIST)/$(APPID_ANDROID)_$(ANDROID_VERSION_CODE).apk
|
||||||
|
|
||||||
|
.PHONY: android
|
||||||
|
android: $(APK) ## build the unsigned release APK under its publishing name
|
||||||
|
|
||||||
|
$(APK): $(shell find android/app/src -type f 2>/dev/null) android/app/build.gradle.kts
|
||||||
|
cd android && $(GRADLE) --no-daemon assembleRelease \
|
||||||
|
-PversionName=$(VERSION_ANDROID) -PversionCode=$(ANDROID_VERSION_CODE)
|
||||||
|
@mkdir -p $(DIST)
|
||||||
|
cp $(APK_BUILT) $@
|
||||||
|
@printf 'built %s\n' "$@"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- publish
|
||||||
|
|
||||||
|
FDROID_HOST ?= rcmd@garage.rcmd.space
|
||||||
|
FDROID_UNSIGNED ?= /var/lib/fdroid/unsigned
|
||||||
|
FDROID_METADATA ?= /var/lib/fdroid/metadata
|
||||||
|
|
||||||
|
.PHONY: android-publish
|
||||||
|
android-publish: $(APK) ## upload the APK and metadata to the private F-Droid repo (manual)
|
||||||
|
@printf 'publishing %s to %s:%s\n' "$(notdir $(APK))" "$(FDROID_HOST)" "$(FDROID_UNSIGNED)"
|
||||||
|
scp $(APK) $(FDROID_HOST):$(FDROID_UNSIGNED)/
|
||||||
|
scp android/metadata/$(APPID_ANDROID).yml $(FDROID_HOST):$(FDROID_METADATA)/
|
||||||
|
@printf 'uploaded. Sign and publish on the repo host with `fdroid publish && fdroid update`.\n'
|
||||||
|
|
||||||
|
.PHONY: dist
|
||||||
|
dist: deb windows ## build every artifact this host can build
|
||||||
|
@printf '\nartifacts in %s:\n' "$(DIST)"
|
||||||
|
@ls -1 $(DIST)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- versions
|
||||||
|
|
||||||
|
.PHONY: bump
|
||||||
|
bump: ## bump a version: make bump PLATFORM=android PART=minor
|
||||||
|
scripts/bump.sh "$(PLATFORM)" "$(PART)"
|
||||||
|
|
||||||
|
.PHONY: changelog
|
||||||
|
changelog: ## add a Debian changelog entry: make changelog MSG="what changed"
|
||||||
|
scripts/changelog.sh "$(VERSION_LINUX)" "$(MSG)"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ local runs
|
||||||
|
|
||||||
|
.PHONY: run-server
|
||||||
|
run-server: $(HOST_SERVER) ## run a server on 127.0.0.1:8765 with data in ./.devdata
|
||||||
|
KEEPORG_ADMIN_PASSWORD=$${KEEPORG_ADMIN_PASSWORD:-dev-password-1} \
|
||||||
|
$(HOST_SERVER) -addr 127.0.0.1:8765 -data .devdata -url http://127.0.0.1:8765
|
||||||
|
|
||||||
|
.PHONY: run-client
|
||||||
|
run-client: $(HOST_CLIENT) ## run the sync agent with the local configuration
|
||||||
|
$(HOST_CLIENT) run
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# KeepOrg
|
||||||
|
|
||||||
|
A synchronization adapter for KeePass. Your databases stay ordinary `.kdbx`
|
||||||
|
files that KeePassXC, KeePass2 or KeePassDX opens directly; a server keeps
|
||||||
|
them in step between everyone who has access.
|
||||||
|
|
||||||
|
The server never opens a database and cannot read one. Master passwords stay
|
||||||
|
on the clients.
|
||||||
|
|
||||||
|
- **Server** (`keeporgd`, Linux): hosts many KDBX databases, controls access
|
||||||
|
with a tree of user groups, accepts registrations by invite link or OIDC,
|
||||||
|
and pushes updates to connected clients.
|
||||||
|
- **Desktop client** (`keeporg`, Linux, macOS, Windows, BSD): mirrors every
|
||||||
|
assigned database into one directory as a real file, pushes each save and
|
||||||
|
applies what others push.
|
||||||
|
- **Android client**: the same, published to every file picker as a storage
|
||||||
|
provider.
|
||||||
|
|
||||||
|
No third-party dependencies, no cgo, no FUSE.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make check # gofmt, vet, tests
|
||||||
|
make all # keeporgd and keeporg for this host
|
||||||
|
|
||||||
|
# a server for trying it out, on 127.0.0.1:8765
|
||||||
|
KEEPORG_ADMIN_PASSWORD=dev-password-1 make run-server
|
||||||
|
|
||||||
|
# in another shell
|
||||||
|
build/linux-arm64/keeporg login -server http://127.0.0.1:8765 -user admin
|
||||||
|
build/linux-arm64/keeporg groups
|
||||||
|
build/linux-arm64/keeporg db -create "Team Vault" -group <group-id>
|
||||||
|
build/linux-arm64/keeporg run
|
||||||
|
```
|
||||||
|
|
||||||
|
Then save a new KDBX database as `Team Vault.kdbx` in `~/KeepOrg` from your
|
||||||
|
KeePass application. It becomes version 1 on the server, and every later save
|
||||||
|
is pushed automatically.
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make deb # Debian packages for the client and the server
|
||||||
|
make dmg # macOS disk image with an /Applications symlink (on macOS)
|
||||||
|
make windows # Windows zip with a Task Scheduler job
|
||||||
|
make android # unsigned release APK (needs the Android SDK)
|
||||||
|
make help # every target
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [docs/HANDOVER.md](docs/HANDOVER.md) — architecture, the decisions behind
|
||||||
|
it, operations, and what is left to do.
|
||||||
|
- [docs/PROTOCOL.md](docs/PROTOCOL.md) — the HTTP API.
|
||||||
|
- [android/README.md](android/README.md) — the Android module.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# KeepOrg for Android
|
||||||
|
|
||||||
|
A KDBX synchronization adapter with no third-party dependencies: the server
|
||||||
|
is reached with `java.net.HttpURLConnection`, JSON is parsed with the
|
||||||
|
platform's `org.json`, and the two screens are built in code.
|
||||||
|
|
||||||
|
## How a KeePass application sees it
|
||||||
|
|
||||||
|
`KeepOrgProvider` is a `DocumentsProvider`, so the mirrored databases appear
|
||||||
|
in every file picker under "KeepOrg". KeePassDX and Keepass2Android open them
|
||||||
|
like any other document. When the application closes a file it had opened for
|
||||||
|
writing, the provider triggers a synchronization pass, which is how a save
|
||||||
|
reaches the server.
|
||||||
|
|
||||||
|
`SyncService` is a foreground service that holds the server's event stream
|
||||||
|
open, so a change pushed by a colleague arrives without opening the app. The
|
||||||
|
user turns it on from the main screen; it is not started at boot.
|
||||||
|
|
||||||
|
The merge policy in `Mirror.decide` is the same table as the desktop client's
|
||||||
|
`internal/client/decide.go`. Keep the two in step.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
The Gradle wrapper is not committed. Generate it once with a local Gradle
|
||||||
|
8.9 or newer, or let the Makefile call Gradle directly:
|
||||||
|
|
||||||
|
```
|
||||||
|
gradle wrapper --gradle-version 8.9 # once, optional
|
||||||
|
make android # unsigned release APK
|
||||||
|
make android-publish # upload to the private F-Droid repo
|
||||||
|
```
|
||||||
|
|
||||||
|
The release build is deliberately unsigned: the private F-Droid repository
|
||||||
|
signs what it publishes.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version comes from versions.mk through the Makefile:
|
||||||
|
// ./gradlew assembleRelease -PversionName=0.1.0 -PversionCode=1
|
||||||
|
val appVersionName = (project.findProperty("versionName") as String?) ?: "0.0.0"
|
||||||
|
val appVersionCode = ((project.findProperty("versionCode") as String?) ?: "1").toInt()
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "cc.tiredsysadmin.android.keeporg"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "cc.tiredsysadmin.android.keeporg"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = appVersionCode
|
||||||
|
versionName = appVersionName
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
// Unsigned on purpose: the private F-Droid repository signs it.
|
||||||
|
signingConfig = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
dependenciesInfo {
|
||||||
|
// Keep the APK free of the Google dependency metadata blob so the
|
||||||
|
// build is reproducible for F-Droid.
|
||||||
|
includeInApk = false
|
||||||
|
includeInBundle = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@android:style/Theme.DeviceDefault.DayNight">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/app_name">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".LoginActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:label="@string/sign_in" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".SyncService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<!-- The databases appear in every file picker as a storage provider,
|
||||||
|
so KeePassDX or Keepass2Android opens them like any other file. -->
|
||||||
|
<provider
|
||||||
|
android:name=".KeepOrgProvider"
|
||||||
|
android:authorities="cc.tiredsysadmin.android.keeporg.documents"
|
||||||
|
android:exported="true"
|
||||||
|
android:grantUriPermissions="true"
|
||||||
|
android:permission="android.permission.MANAGE_DOCUMENTS">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||||
|
</intent-filter>
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package cc.tiredsysadmin.android.keeporg
|
||||||
|
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
/** A structured failure from the server. */
|
||||||
|
class ApiException(
|
||||||
|
val status: Int,
|
||||||
|
val code: String,
|
||||||
|
message: String,
|
||||||
|
val currentVersion: Long = 0,
|
||||||
|
val mergeStrategy: String = "",
|
||||||
|
) : IOException(message) {
|
||||||
|
val isConflict: Boolean get() = code == "conflict" || code == "force_not_allowed"
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Db(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val perm: String,
|
||||||
|
val mergeStrategy: String,
|
||||||
|
val version: Long,
|
||||||
|
val sha256: String,
|
||||||
|
val updatedBy: String,
|
||||||
|
) {
|
||||||
|
val canWrite: Boolean get() = perm == "write" || perm == "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SessionInfo(val token: String, val username: String, val totpEnabled: Boolean)
|
||||||
|
|
||||||
|
data class DeviceStart(
|
||||||
|
val deviceCode: String,
|
||||||
|
val userCode: String,
|
||||||
|
val verificationUrl: String,
|
||||||
|
val interval: Int,
|
||||||
|
val expiresIn: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PushResult(val version: Long, val sha256: String, val unchanged: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The KeepOrg HTTP API over the platform's own HTTP client. The server
|
||||||
|
* contract is small enough that a dependency would cost more than it saves.
|
||||||
|
*/
|
||||||
|
class Api(baseUrl: String, var token: String?) {
|
||||||
|
|
||||||
|
private val base: String = baseUrl.trimEnd('/')
|
||||||
|
|
||||||
|
val serverUrl: String get() = base
|
||||||
|
|
||||||
|
fun login(user: String, password: String, totp: String, label: String): SessionInfo {
|
||||||
|
val body = JSONObject()
|
||||||
|
.put("username", user).put("password", password)
|
||||||
|
.put("totp", totp).put("label", label)
|
||||||
|
return session(request("POST", "/api/v1/auth/login", body))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deviceStart(label: String): DeviceStart {
|
||||||
|
val o = request("POST", "/api/v1/auth/device/start", JSONObject().put("label", label))
|
||||||
|
return DeviceStart(
|
||||||
|
o.getString("device_code"),
|
||||||
|
o.getString("user_code"),
|
||||||
|
o.getString("verification_url"),
|
||||||
|
o.optInt("interval", 2),
|
||||||
|
o.optInt("expires_in", 600),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns null while the browser has not approved the login yet. */
|
||||||
|
fun devicePoll(deviceCode: String): SessionInfo? {
|
||||||
|
return try {
|
||||||
|
session(request("POST", "/api/v1/auth/device/poll",
|
||||||
|
JSONObject().put("device_code", deviceCode)))
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
if (e.status == 202 || e.code == "pending") null else throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
try {
|
||||||
|
request("POST", "/api/v1/auth/logout", null)
|
||||||
|
} catch (_: IOException) {
|
||||||
|
// A local sign-out must succeed even when the server is away.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun databases(): List<Db> {
|
||||||
|
val arr = request("GET", "/api/v1/databases", null).getJSONArray("databases")
|
||||||
|
return (0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
Db(
|
||||||
|
id = o.getString("id"),
|
||||||
|
name = o.getString("name"),
|
||||||
|
perm = o.optString("perm"),
|
||||||
|
mergeStrategy = o.optString("merge_strategy"),
|
||||||
|
version = o.optLong("version"),
|
||||||
|
sha256 = o.optString("sha256"),
|
||||||
|
updatedBy = o.optString("updated_by"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Downloads the current bytes and version; version 0 means nothing stored. */
|
||||||
|
fun pull(dbId: String): Pair<ByteArray, Long> {
|
||||||
|
val c = open("GET", "/api/v1/databases/${enc(dbId)}/blob")
|
||||||
|
try {
|
||||||
|
val status = c.responseCode
|
||||||
|
if (status == 204) return Pair(ByteArray(0), 0L)
|
||||||
|
if (status != 200) throw errorOf(c, status)
|
||||||
|
val data = c.inputStream.use { it.readAllBytesCompat() }
|
||||||
|
val version = c.getHeaderField("ETag")?.trim('"')?.toLongOrNull() ?: 0L
|
||||||
|
return Pair(data, version)
|
||||||
|
} finally {
|
||||||
|
c.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun push(dbId: String, baseVersion: Long, data: ByteArray, force: Boolean): PushResult {
|
||||||
|
val c = open("PUT", "/api/v1/databases/${enc(dbId)}/blob")
|
||||||
|
try {
|
||||||
|
c.setRequestProperty("If-Match", if (force) "*" else baseVersion.toString())
|
||||||
|
c.setRequestProperty("Content-Type", "application/octet-stream")
|
||||||
|
c.doOutput = true
|
||||||
|
c.setFixedLengthStreamingMode(data.size)
|
||||||
|
c.outputStream.use { it.write(data) }
|
||||||
|
val status = c.responseCode
|
||||||
|
if (status != 200) throw errorOf(c, status)
|
||||||
|
val o = JSONObject(c.inputStream.use { it.readAllBytesCompat() }.decodeToString())
|
||||||
|
return PushResult(o.getLong("version"), o.optString("sha256"), o.optBoolean("unchanged"))
|
||||||
|
} finally {
|
||||||
|
c.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the server's event stream until it ends or the thread is
|
||||||
|
* interrupted. Events only ever trigger a full reconcile, so a dropped
|
||||||
|
* stream costs a delay, never data.
|
||||||
|
*/
|
||||||
|
fun events(onEvent: (JSONObject) -> Unit) {
|
||||||
|
val url = URL("$base/api/v1/events?access_token=" + enc(token ?: ""))
|
||||||
|
val c = url.openConnection() as HttpURLConnection
|
||||||
|
c.requestMethod = "GET"
|
||||||
|
c.setRequestProperty("Accept", "text/event-stream")
|
||||||
|
c.connectTimeout = 15_000
|
||||||
|
// The server sends a keepalive comment every 25 seconds.
|
||||||
|
c.readTimeout = 70_000
|
||||||
|
try {
|
||||||
|
if (c.responseCode != 200) throw errorOf(c, c.responseCode)
|
||||||
|
c.inputStream.bufferedReader().use { r ->
|
||||||
|
var data = StringBuilder()
|
||||||
|
while (true) {
|
||||||
|
val line = r.readLine() ?: return
|
||||||
|
when {
|
||||||
|
line.isEmpty() -> {
|
||||||
|
if (data.isNotEmpty()) {
|
||||||
|
runCatching { onEvent(JSONObject(data.toString())) }
|
||||||
|
data = StringBuilder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line.startsWith(":") -> Unit
|
||||||
|
line.startsWith("data:") -> data.append(line.removePrefix("data:").trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
c.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- plumbing ----
|
||||||
|
|
||||||
|
private fun session(o: JSONObject): SessionInfo {
|
||||||
|
val user = o.optJSONObject("user")
|
||||||
|
return SessionInfo(
|
||||||
|
o.getString("token"),
|
||||||
|
user?.optString("username") ?: "",
|
||||||
|
user?.optBoolean("totp_enabled") ?: false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun open(method: String, path: String): HttpURLConnection {
|
||||||
|
val c = URL(base + path).openConnection() as HttpURLConnection
|
||||||
|
c.requestMethod = method
|
||||||
|
c.connectTimeout = 15_000
|
||||||
|
c.readTimeout = 60_000
|
||||||
|
c.setRequestProperty("Accept", "application/json")
|
||||||
|
token?.takeIf { it.isNotEmpty() }?.let { c.setRequestProperty("Authorization", "Bearer $it") }
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun request(method: String, path: String, body: JSONObject?): JSONObject {
|
||||||
|
val c = open(method, path)
|
||||||
|
try {
|
||||||
|
if (body != null) {
|
||||||
|
c.setRequestProperty("Content-Type", "application/json")
|
||||||
|
c.doOutput = true
|
||||||
|
c.outputStream.use { it.write(body.toString().toByteArray()) }
|
||||||
|
}
|
||||||
|
val status = c.responseCode
|
||||||
|
if (status < 200 || status > 299) throw errorOf(c, status)
|
||||||
|
if (status == 202) throw ApiException(202, "pending", "pending")
|
||||||
|
val text = c.inputStream.use { it.readAllBytesCompat() }.decodeToString()
|
||||||
|
return if (text.isBlank()) JSONObject() else JSONObject(text)
|
||||||
|
} finally {
|
||||||
|
c.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun errorOf(c: HttpURLConnection, status: Int): ApiException {
|
||||||
|
val text = try {
|
||||||
|
(c.errorStream ?: c.inputStream)?.use { it.readAllBytesCompat() }?.decodeToString() ?: ""
|
||||||
|
} catch (_: IOException) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
val o = runCatching { JSONObject(text) }.getOrNull()
|
||||||
|
return ApiException(
|
||||||
|
status = status,
|
||||||
|
code = o?.optString("code") ?: "",
|
||||||
|
message = o?.optString("error")?.takeIf { it.isNotEmpty() } ?: "server returned $status",
|
||||||
|
currentVersion = o?.optLong("current_version") ?: 0L,
|
||||||
|
mergeStrategy = o?.optString("merge_strategy") ?: "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enc(s: String): String = URLEncoder.encode(s, "UTF-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** readAllBytes() needs API 33; this works on every supported release. */
|
||||||
|
internal fun InputStream.readAllBytesCompat(): ByteArray {
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
val buf = ByteArray(16 * 1024)
|
||||||
|
while (true) {
|
||||||
|
val n = read(buf)
|
||||||
|
if (n < 0) break
|
||||||
|
out.write(buf, 0, n)
|
||||||
|
}
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package cc.tiredsysadmin.android.keeporg
|
||||||
|
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.database.MatrixCursor
|
||||||
|
import android.os.CancellationSignal
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.HandlerThread
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import android.provider.DocumentsContract.Document
|
||||||
|
import android.provider.DocumentsContract.Root
|
||||||
|
import android.provider.DocumentsProvider
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes the mirrored databases as a storage provider, so any KeePass
|
||||||
|
* application on the device opens them through the normal file picker and
|
||||||
|
* saves back into them. A save closes the descriptor, which is where a
|
||||||
|
* synchronization pass is triggered.
|
||||||
|
*/
|
||||||
|
class KeepOrgProvider : DocumentsProvider() {
|
||||||
|
|
||||||
|
private lateinit var closeThread: HandlerThread
|
||||||
|
private lateinit var closeHandler: Handler
|
||||||
|
|
||||||
|
override fun onCreate(): Boolean {
|
||||||
|
closeThread = HandlerThread("keeporg-doc-close").apply { start() }
|
||||||
|
closeHandler = Handler(closeThread.looper)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun shutdown() {
|
||||||
|
closeThread.quitSafely()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prefs() = Prefs(requireContext())
|
||||||
|
|
||||||
|
private fun mirrorDir(): File = File(requireContext().filesDir, "databases").apply { mkdirs() }
|
||||||
|
|
||||||
|
private fun requireContext() = context ?: throw FileNotFoundException("no context")
|
||||||
|
|
||||||
|
override fun queryRoots(projection: Array<out String>?): Cursor {
|
||||||
|
val c = MatrixCursor(projection ?: ROOT_PROJECTION)
|
||||||
|
val p = prefs()
|
||||||
|
if (!p.signedIn) return c // signed out: the provider shows no root at all
|
||||||
|
c.newRow().apply {
|
||||||
|
add(Root.COLUMN_ROOT_ID, ROOT_ID)
|
||||||
|
add(Root.COLUMN_DOCUMENT_ID, ROOT_DOC)
|
||||||
|
add(Root.COLUMN_TITLE, requireContext().getString(R.string.app_name))
|
||||||
|
add(Root.COLUMN_SUMMARY, p.username.ifEmpty { p.serverUrl })
|
||||||
|
add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_IS_CHILD or Root.FLAG_LOCAL_ONLY)
|
||||||
|
add(Root.COLUMN_MIME_TYPES, KDBX_MIME)
|
||||||
|
add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun queryDocument(documentId: String, projection: Array<out String>?): Cursor {
|
||||||
|
val c = MatrixCursor(projection ?: DOC_PROJECTION)
|
||||||
|
if (documentId == ROOT_DOC) {
|
||||||
|
c.newRow().apply {
|
||||||
|
add(Document.COLUMN_DOCUMENT_ID, ROOT_DOC)
|
||||||
|
add(Document.COLUMN_DISPLAY_NAME, requireContext().getString(R.string.provider_root))
|
||||||
|
add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR)
|
||||||
|
add(Document.COLUMN_FLAGS, Document.FLAG_DIR_SUPPORTS_CREATE)
|
||||||
|
add(Document.COLUMN_SIZE, null)
|
||||||
|
add(Document.COLUMN_LAST_MODIFIED, mirrorDir().lastModified())
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
addFileRow(c, fileOf(documentId))
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun queryChildDocuments(
|
||||||
|
parentDocumentId: String,
|
||||||
|
projection: Array<out String>?,
|
||||||
|
sortOrder: String?,
|
||||||
|
): Cursor {
|
||||||
|
if (parentDocumentId != ROOT_DOC) throw FileNotFoundException("not a directory")
|
||||||
|
val c = MatrixCursor(projection ?: DOC_PROJECTION)
|
||||||
|
mirrorDir().listFiles()
|
||||||
|
?.filter { it.isFile && it.name.endsWith(".kdbx") && !it.name.endsWith(".keeporg-tmp") }
|
||||||
|
?.sortedBy { it.name }
|
||||||
|
?.forEach { addFileRow(c, it) }
|
||||||
|
// Ask for a fresh pass so a picker that is open shows current files.
|
||||||
|
Syncer.request(requireContext(), "picker listed documents")
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun openDocument(
|
||||||
|
documentId: String,
|
||||||
|
mode: String,
|
||||||
|
signal: CancellationSignal?,
|
||||||
|
): ParcelFileDescriptor {
|
||||||
|
val file = fileOf(documentId)
|
||||||
|
val access = ParcelFileDescriptor.parseMode(mode)
|
||||||
|
if (!mode.contains("w")) return ParcelFileDescriptor.open(file, access)
|
||||||
|
// The application is about to save: reconcile once it has closed the
|
||||||
|
// file, which is the moment the new bytes are complete on disk.
|
||||||
|
return ParcelFileDescriptor.open(file, access, closeHandler) {
|
||||||
|
Syncer.request(requireContext(), "saved $documentId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creating a document is allowed only under the name of a database the
|
||||||
|
* server has assigned to this device but that has no content yet. That
|
||||||
|
* is how a brand new shared database gets its first bytes from a KeePass
|
||||||
|
* application, without the picker inventing files the server knows
|
||||||
|
* nothing about.
|
||||||
|
*/
|
||||||
|
override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String {
|
||||||
|
if (parentDocumentId != ROOT_DOC) throw FileNotFoundException("not a directory")
|
||||||
|
val wanted = if (displayName.endsWith(".kdbx")) displayName else "$displayName.kdbx"
|
||||||
|
val p = prefs()
|
||||||
|
val known = p.trackedIds.any { p.state(it).file == wanted }
|
||||||
|
if (!known) {
|
||||||
|
throw FileNotFoundException(
|
||||||
|
"no database named ${wanted.removeSuffix(".kdbx")} is assigned to you; " +
|
||||||
|
"create it on the server first"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val file = File(mirrorDir(), wanted)
|
||||||
|
if (!file.exists() && !file.createNewFile()) {
|
||||||
|
throw FileNotFoundException("could not create $wanted")
|
||||||
|
}
|
||||||
|
return wanted
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the conflict copies this app wrote may be deleted from a picker. */
|
||||||
|
override fun deleteDocument(documentId: String) {
|
||||||
|
if (!isDisposable(documentId)) {
|
||||||
|
throw UnsupportedOperationException("a synced database cannot be deleted here")
|
||||||
|
}
|
||||||
|
if (!fileOf(documentId).delete()) throw FileNotFoundException(documentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean =
|
||||||
|
parentDocumentId == ROOT_DOC && documentId != ROOT_DOC
|
||||||
|
|
||||||
|
private fun addFileRow(c: MatrixCursor, file: File) {
|
||||||
|
var flags = Document.FLAG_SUPPORTS_WRITE
|
||||||
|
if (isDisposable(file.name)) flags = flags or Document.FLAG_SUPPORTS_DELETE
|
||||||
|
c.newRow().apply {
|
||||||
|
add(Document.COLUMN_DOCUMENT_ID, file.name)
|
||||||
|
add(Document.COLUMN_DISPLAY_NAME, file.name)
|
||||||
|
add(Document.COLUMN_MIME_TYPE, KDBX_MIME)
|
||||||
|
add(Document.COLUMN_SIZE, file.length())
|
||||||
|
add(Document.COLUMN_LAST_MODIFIED, file.lastModified())
|
||||||
|
add(Document.COLUMN_FLAGS, flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fileOf(documentId: String): File {
|
||||||
|
if (documentId.contains('/') || documentId.contains("..")) {
|
||||||
|
throw FileNotFoundException("bad document id")
|
||||||
|
}
|
||||||
|
val f = File(mirrorDir(), documentId)
|
||||||
|
if (!f.isFile) throw FileNotFoundException(documentId)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isDisposable(name: String) =
|
||||||
|
name.contains(".conflict-") || name.contains(".server-v")
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val AUTHORITY = "cc.tiredsysadmin.android.keeporg.documents"
|
||||||
|
const val ROOT_ID = "keeporg"
|
||||||
|
const val ROOT_DOC = "root"
|
||||||
|
const val KDBX_MIME = "application/x-keepass2"
|
||||||
|
|
||||||
|
private val ROOT_PROJECTION = arrayOf(
|
||||||
|
Root.COLUMN_ROOT_ID, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_TITLE,
|
||||||
|
Root.COLUMN_SUMMARY, Root.COLUMN_FLAGS, Root.COLUMN_MIME_TYPES, Root.COLUMN_ICON,
|
||||||
|
)
|
||||||
|
private val DOC_PROJECTION = arrayOf(
|
||||||
|
Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, Document.COLUMN_MIME_TYPE,
|
||||||
|
Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package cc.tiredsysadmin.android.keeporg
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.InputType
|
||||||
|
import android.widget.TextView
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs the device in, either with a password and the built-in second
|
||||||
|
* factor, or by approving a code in a browser, which is also how single
|
||||||
|
* sign-on works.
|
||||||
|
*/
|
||||||
|
class LoginActivity : Activity() {
|
||||||
|
|
||||||
|
private lateinit var message: TextView
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val prefs = Prefs(this)
|
||||||
|
val root = Ui.column(this)
|
||||||
|
|
||||||
|
val server = Ui.field(this, "https://keeporg.example.com", InputType.TYPE_TEXT_VARIATION_URI)
|
||||||
|
server.setText(prefs.serverUrl)
|
||||||
|
val user = Ui.field(this, "username", InputType.TYPE_CLASS_TEXT)
|
||||||
|
val password = Ui.field(
|
||||||
|
this, "password",
|
||||||
|
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||||
|
)
|
||||||
|
val totp = Ui.field(this, "one-time code (if enabled)", InputType.TYPE_CLASS_NUMBER)
|
||||||
|
message = Ui.body(this)
|
||||||
|
|
||||||
|
root.addView(Ui.heading(this, getString(R.string.sign_in)))
|
||||||
|
root.addView(Ui.label(this, "Server"))
|
||||||
|
root.addView(server)
|
||||||
|
root.addView(Ui.label(this, "Username"))
|
||||||
|
root.addView(user)
|
||||||
|
root.addView(Ui.label(this, "Password"))
|
||||||
|
root.addView(password)
|
||||||
|
root.addView(Ui.label(this, "Second factor"))
|
||||||
|
root.addView(totp)
|
||||||
|
root.addView(Ui.button(this, "Sign in") {
|
||||||
|
val url = server.text.toString().trim().trimEnd('/')
|
||||||
|
if (url.isEmpty()) {
|
||||||
|
message.text = "Enter the server address."
|
||||||
|
return@button
|
||||||
|
}
|
||||||
|
prefs.serverUrl = url
|
||||||
|
message.text = "Signing in..."
|
||||||
|
val api = Api(url, null)
|
||||||
|
Ui.background(this, {
|
||||||
|
api.login(user.text.toString().trim(), password.text.toString(),
|
||||||
|
totp.text.toString().trim(), android.os.Build.MODEL ?: "android")
|
||||||
|
}) { result ->
|
||||||
|
result.fold({ finishSignIn(it) }, { failed(it) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
root.addView(Ui.button(this, "Sign in with a browser or SSO") {
|
||||||
|
val url = server.text.toString().trim().trimEnd('/')
|
||||||
|
if (url.isEmpty()) {
|
||||||
|
message.text = "Enter the server address."
|
||||||
|
return@button
|
||||||
|
}
|
||||||
|
prefs.serverUrl = url
|
||||||
|
browserSignIn(Api(url, null))
|
||||||
|
})
|
||||||
|
root.addView(message)
|
||||||
|
setContentView(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs the device flow: show a code, open a browser, wait for approval. */
|
||||||
|
private fun browserSignIn(api: Api) {
|
||||||
|
message.text = "Opening a browser..."
|
||||||
|
Ui.background(this, { api.deviceStart(android.os.Build.MODEL ?: "android") }) { started ->
|
||||||
|
started.fold({ start ->
|
||||||
|
message.text = "Approve the code ${start.userCode} in the browser."
|
||||||
|
runCatching {
|
||||||
|
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(start.verificationUrl)))
|
||||||
|
}
|
||||||
|
Ui.background(this, { poll(api, start) }) { polled ->
|
||||||
|
polled.fold({ session ->
|
||||||
|
if (session == null) {
|
||||||
|
message.text = "The approval window expired. Try again."
|
||||||
|
} else {
|
||||||
|
finishSignIn(session)
|
||||||
|
}
|
||||||
|
}, { failed(it) })
|
||||||
|
}
|
||||||
|
}, { failed(it) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun poll(api: Api, start: DeviceStart): SessionInfo? {
|
||||||
|
val deadline = System.currentTimeMillis() + start.expiresIn * 1000L
|
||||||
|
val interval = (start.interval.coerceAtLeast(1)) * 1000L
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
Thread.sleep(interval)
|
||||||
|
api.devicePoll(start.deviceCode)?.let { return it }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finishSignIn(session: SessionInfo) {
|
||||||
|
val prefs = Prefs(this)
|
||||||
|
prefs.token = session.token
|
||||||
|
prefs.username = session.username
|
||||||
|
Syncer.request(this, "signed in")
|
||||||
|
startActivity(Intent(this, MainActivity::class.java))
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun failed(e: Throwable) {
|
||||||
|
message.text = e.message ?: "Sign-in failed."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package cc.tiredsysadmin.android.keeporg
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.TextView
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One screen: which databases are mirrored, what state each is in, and the
|
||||||
|
* switch that keeps the device listening for other people's changes.
|
||||||
|
*/
|
||||||
|
class MainActivity : Activity() {
|
||||||
|
|
||||||
|
private lateinit var prefs: Prefs
|
||||||
|
private lateinit var status: TextView
|
||||||
|
private lateinit var watchButton: Button
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
prefs = Prefs(this)
|
||||||
|
if (!prefs.signedIn) {
|
||||||
|
startActivity(Intent(this, LoginActivity::class.java))
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val root = Ui.column(this)
|
||||||
|
root.addView(Ui.heading(this, getString(R.string.app_name)))
|
||||||
|
root.addView(Ui.body(this, "${prefs.username} at ${prefs.serverUrl}"))
|
||||||
|
root.addView(
|
||||||
|
Ui.label(
|
||||||
|
this,
|
||||||
|
"Open these databases from any KeePass application: pick " +
|
||||||
|
"\"KeepOrg\" in its file picker."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
status = Ui.body(this, "Loading...")
|
||||||
|
root.addView(status)
|
||||||
|
|
||||||
|
root.addView(Ui.button(this, "Sync now") { refresh(force = true) })
|
||||||
|
watchButton = Ui.button(this, watchLabel()) { toggleWatch() }
|
||||||
|
root.addView(watchButton)
|
||||||
|
root.addView(Ui.button(this, "Sign out") { signOut() })
|
||||||
|
setContentView(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
if (!prefs.signedIn) return
|
||||||
|
// Restart the watcher if the system stopped it while we were away.
|
||||||
|
if (prefs.watchEnabled) SyncService.start(this)
|
||||||
|
refresh(force = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refresh(force: Boolean) {
|
||||||
|
status.text = if (force) "Synchronizing..." else status.text
|
||||||
|
val api = Syncer.apiOf(this) ?: return
|
||||||
|
Syncer.request(this, if (force) "user asked" else "screen opened") { }
|
||||||
|
Ui.background(this, { api.databases() }) { result ->
|
||||||
|
result.fold({ dbs ->
|
||||||
|
status.text = if (dbs.isEmpty()) {
|
||||||
|
"No databases are assigned to this account yet."
|
||||||
|
} else {
|
||||||
|
dbs.joinToString("\n\n") { db ->
|
||||||
|
val state = prefs.status(db.id).ifEmpty { "not synced yet" }
|
||||||
|
"${db.name}\n ${db.perm}, ${db.mergeStrategy}, v${db.version}\n $state"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { e ->
|
||||||
|
status.text = "Could not reach the server: ${e.message}"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun watchLabel() =
|
||||||
|
if (prefs.watchEnabled) "Stop watching for changes" else "Keep watching for changes"
|
||||||
|
|
||||||
|
private fun toggleWatch() {
|
||||||
|
if (prefs.watchEnabled) {
|
||||||
|
prefs.watchEnabled = false
|
||||||
|
SyncService.stop(this)
|
||||||
|
} else {
|
||||||
|
if (Build.VERSION.SDK_INT >= 33 &&
|
||||||
|
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prefs.watchEnabled = true
|
||||||
|
SyncService.start(this)
|
||||||
|
}
|
||||||
|
watchButton.text = watchLabel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRequestPermissionsResult(
|
||||||
|
requestCode: Int,
|
||||||
|
permissions: Array<out String>,
|
||||||
|
grantResults: IntArray,
|
||||||
|
) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
|
if (requestCode == 1) {
|
||||||
|
prefs.watchEnabled = true
|
||||||
|
SyncService.start(this)
|
||||||
|
watchButton.text = watchLabel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun signOut() {
|
||||||
|
val api = Syncer.apiOf(this)
|
||||||
|
SyncService.stop(this)
|
||||||
|
prefs.signOut()
|
||||||
|
Ui.background(this, { api?.logout() }) {
|
||||||
|
startActivity(Intent(this, LoginActivity::class.java))
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package cc.tiredsysadmin.android.keeporg
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps a directory of real .kdbx files in step with the server. The files
|
||||||
|
* are published to every file picker by [KeepOrgProvider], so KeePassDX or
|
||||||
|
* Keepass2Android opens them as ordinary documents.
|
||||||
|
*
|
||||||
|
* The decision table below is the same policy the desktop client applies, so
|
||||||
|
* both platforms behave identically in a conflict.
|
||||||
|
*/
|
||||||
|
class Mirror(private val context: Context, private val api: Api, private val prefs: Prefs) {
|
||||||
|
|
||||||
|
enum class Action { NOTHING, PULL, PUSH, FORCE_PUSH, SERVER_WINS, RESTORE, MANUAL }
|
||||||
|
|
||||||
|
/** The mirror directory, private to the app. */
|
||||||
|
val dir: File = File(context.filesDir, "databases").apply { mkdirs() }
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun syncAll(): Result<Unit> {
|
||||||
|
val dbs = try {
|
||||||
|
api.databases()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
return Result.failure(e)
|
||||||
|
}
|
||||||
|
var firstError: Throwable? = null
|
||||||
|
for (db in dbs) {
|
||||||
|
try {
|
||||||
|
reconcile(db)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
prefs.putStatus(db.id, "error: ${e.message}")
|
||||||
|
Log.w(TAG, "reconcile ${db.name}", e)
|
||||||
|
if (firstError == null) firstError = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val seen = dbs.map { it.id }.toSet()
|
||||||
|
for (gone in prefs.trackedIds - seen) {
|
||||||
|
// The file itself stays: deleting someone's password database is
|
||||||
|
// not this app's decision.
|
||||||
|
prefs.forgetState(gone)
|
||||||
|
}
|
||||||
|
prefs.trackedIds = seen
|
||||||
|
notifyPicker()
|
||||||
|
return if (firstError == null) Result.success(Unit) else Result.failure(firstError)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decides and applies the one action this database needs now. */
|
||||||
|
private fun reconcile(db: Db) {
|
||||||
|
val st = trackedState(db)
|
||||||
|
val file = File(dir, st.file)
|
||||||
|
val localExists = file.isFile
|
||||||
|
val local = if (localExists) file.readBytes() else ByteArray(0)
|
||||||
|
val localSha = if (localExists) digest(local) else ""
|
||||||
|
|
||||||
|
val action = decide(
|
||||||
|
localExists = localExists,
|
||||||
|
localSha = localSha,
|
||||||
|
syncedSha = st.sha256,
|
||||||
|
syncedVersion = st.version,
|
||||||
|
remoteSha = db.sha256,
|
||||||
|
remoteVersion = db.version,
|
||||||
|
canWrite = db.canWrite,
|
||||||
|
strategy = db.mergeStrategy,
|
||||||
|
conflictSeen = st.conflictMarker == conflictMarker(db.version, localSha),
|
||||||
|
)
|
||||||
|
if (action != Action.MANUAL && st.conflictMarker.isNotEmpty()) st.conflictMarker = ""
|
||||||
|
|
||||||
|
when (action) {
|
||||||
|
Action.NOTHING -> if (prefs.status(db.id).isEmpty()) prefs.putStatus(db.id, "in sync")
|
||||||
|
|
||||||
|
Action.PULL -> pull(db, st, file)
|
||||||
|
|
||||||
|
Action.PUSH -> push(db, st, file, local, force = false)
|
||||||
|
|
||||||
|
Action.FORCE_PUSH -> push(db, st, file, local, force = true)
|
||||||
|
|
||||||
|
Action.SERVER_WINS, Action.RESTORE -> {
|
||||||
|
val kept = keepConflictCopy(file, local)
|
||||||
|
pull(db, st, file)
|
||||||
|
val why = if (action == Action.RESTORE)
|
||||||
|
"read-only access: your save was kept as ${kept.name}"
|
||||||
|
else
|
||||||
|
"the server copy was newer: your save was kept as ${kept.name}"
|
||||||
|
prefs.putStatus(db.id, why)
|
||||||
|
}
|
||||||
|
|
||||||
|
Action.MANUAL -> {
|
||||||
|
val (remote, version) = api.pull(db.id)
|
||||||
|
val side = File(dir, st.file.removeSuffix(".kdbx") + ".server-v$version.kdbx")
|
||||||
|
writeAtomic(side, remote)
|
||||||
|
st.conflictMarker = conflictMarker(db.version, localSha)
|
||||||
|
prefs.putStatus(db.id, "both copies changed: the server copy is in ${side.name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefs.putState(db.id, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pull(db: Db, st: Prefs.FileState, file: File) {
|
||||||
|
val (data, version) = api.pull(db.id)
|
||||||
|
if (version == 0L || data.isEmpty()) return
|
||||||
|
writeAtomic(file, data)
|
||||||
|
st.version = version
|
||||||
|
st.sha256 = digest(data)
|
||||||
|
prefs.putStatus(db.id, "pulled version $version")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun push(db: Db, st: Prefs.FileState, file: File, data: ByteArray, force: Boolean) {
|
||||||
|
try {
|
||||||
|
val res = api.push(db.id, st.version, data, force)
|
||||||
|
st.version = res.version
|
||||||
|
st.sha256 = res.sha256
|
||||||
|
prefs.putStatus(db.id, if (res.unchanged) "in sync" else "pushed version ${res.version}")
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
when {
|
||||||
|
e.isConflict -> {
|
||||||
|
val strategy = e.mergeStrategy.ifEmpty { db.mergeStrategy }
|
||||||
|
if (strategy == "client-wins" && !force) {
|
||||||
|
push(db, st, file, data, force = true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val kept = keepConflictCopy(file, data)
|
||||||
|
pull(db, st, file)
|
||||||
|
prefs.putStatus(db.id, "the server copy was newer: your save was kept as ${kept.name}")
|
||||||
|
}
|
||||||
|
e.status == 403 -> {
|
||||||
|
val kept = keepConflictCopy(file, data)
|
||||||
|
pull(db, st, file)
|
||||||
|
prefs.putStatus(db.id, "read-only access: your save was kept as ${kept.name}")
|
||||||
|
}
|
||||||
|
else -> throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the state for a database, following a rename made on the server. */
|
||||||
|
private fun trackedState(db: Db): Prefs.FileState {
|
||||||
|
val st = prefs.state(db.id)
|
||||||
|
if (st.file.isEmpty()) {
|
||||||
|
st.name = db.name
|
||||||
|
st.file = fileName(db)
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
if (st.name != db.name) {
|
||||||
|
val want = fileName(db)
|
||||||
|
val old = File(dir, st.file)
|
||||||
|
if (old.isFile && want != st.file) {
|
||||||
|
if (!old.renameTo(File(dir, want))) {
|
||||||
|
Log.w(TAG, "could not rename ${st.file} to $want")
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.name = db.name
|
||||||
|
st.file = want
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fileName(db: Db): String {
|
||||||
|
val base = sanitize(db.name).ifEmpty { "database" }
|
||||||
|
val name = "$base.kdbx"
|
||||||
|
val taken = prefs.trackedIds.any { it != db.id && prefs.state(it).file == name }
|
||||||
|
return if (taken) "$base-${db.id.take(6)}.kdbx" else name
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun keepConflictCopy(file: File, data: ByteArray): File {
|
||||||
|
val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
|
||||||
|
val copy = File(dir, file.name.removeSuffix(".kdbx") + ".conflict-$stamp.kdbx")
|
||||||
|
if (data.isNotEmpty()) writeAtomic(copy, data)
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tells every open file picker that the listing changed. */
|
||||||
|
fun notifyPicker() {
|
||||||
|
val uri = DocumentsContract.buildChildDocumentsUri(KeepOrgProvider.AUTHORITY, KeepOrgProvider.ROOT_DOC)
|
||||||
|
runCatching { context.contentResolver.notifyChange(uri, null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "KeepOrgMirror"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole client-side synchronization policy, kept pure so it can
|
||||||
|
* be read in one place and matched against the desktop client.
|
||||||
|
*/
|
||||||
|
fun decide(
|
||||||
|
localExists: Boolean,
|
||||||
|
localSha: String,
|
||||||
|
syncedSha: String,
|
||||||
|
syncedVersion: Long,
|
||||||
|
remoteSha: String,
|
||||||
|
remoteVersion: Long,
|
||||||
|
canWrite: Boolean,
|
||||||
|
strategy: String,
|
||||||
|
conflictSeen: Boolean,
|
||||||
|
): Action {
|
||||||
|
val localChanged = localExists && localSha != syncedSha
|
||||||
|
val remoteChanged = remoteVersion != syncedVersion || remoteSha != syncedSha
|
||||||
|
return when {
|
||||||
|
!localExists -> if (remoteVersion > 0) Action.PULL else Action.NOTHING
|
||||||
|
localChanged && !remoteChanged -> if (canWrite) Action.PUSH else Action.RESTORE
|
||||||
|
!localChanged && remoteChanged -> Action.PULL
|
||||||
|
localChanged && remoteChanged -> when {
|
||||||
|
!canWrite -> Action.RESTORE
|
||||||
|
strategy == "client-wins" -> Action.FORCE_PUSH
|
||||||
|
strategy == "manual" -> if (conflictSeen) Action.NOTHING else Action.MANUAL
|
||||||
|
else -> Action.SERVER_WINS
|
||||||
|
}
|
||||||
|
else -> Action.NOTHING
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun digest(data: ByteArray): String =
|
||||||
|
MessageDigest.getInstance("SHA-256").digest(data)
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
|
fun conflictMarker(remoteVersion: Long, localSha: String): String =
|
||||||
|
if (localSha.isEmpty()) "" else "$remoteVersion/$localSha"
|
||||||
|
|
||||||
|
/** Replaces a file in one step, so a reader never sees half a database. */
|
||||||
|
fun writeAtomic(target: File, data: ByteArray) {
|
||||||
|
val tmp = File(target.parentFile, target.name + ".keeporg-tmp")
|
||||||
|
tmp.outputStream().use { it.write(data); it.fd.sync() }
|
||||||
|
if (!tmp.renameTo(target)) {
|
||||||
|
tmp.delete()
|
||||||
|
throw IOException("could not replace ${target.name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sanitize(name: String): String = buildString {
|
||||||
|
for (ch in name.trim()) {
|
||||||
|
append(if (ch.isLetterOrDigit() || ch in "-_. ") ch else '-')
|
||||||
|
}
|
||||||
|
}.removeSuffix(".kdbx").trim(' ', '.', '-')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package cc.tiredsysadmin.android.keeporg
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the app remembers: where the server is, this device's session
|
||||||
|
* token, and what this device last agreed with the server about each
|
||||||
|
* database. Master passwords are never stored, because the app never opens a
|
||||||
|
* database; it only moves bytes for the KeePass application that does.
|
||||||
|
*/
|
||||||
|
class Prefs(context: Context) {
|
||||||
|
|
||||||
|
private val sp = context.getSharedPreferences("keeporg", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
var serverUrl: String
|
||||||
|
get() = sp.getString("server_url", "") ?: ""
|
||||||
|
set(v) = sp.edit().putString("server_url", v).apply()
|
||||||
|
|
||||||
|
var token: String
|
||||||
|
get() = sp.getString("token", "") ?: ""
|
||||||
|
set(v) = sp.edit().putString("token", v).apply()
|
||||||
|
|
||||||
|
var username: String
|
||||||
|
get() = sp.getString("username", "") ?: ""
|
||||||
|
set(v) = sp.edit().putString("username", v).apply()
|
||||||
|
|
||||||
|
val signedIn: Boolean get() = serverUrl.isNotEmpty() && token.isNotEmpty()
|
||||||
|
|
||||||
|
/** Whether the user asked this device to keep listening for changes. */
|
||||||
|
var watchEnabled: Boolean
|
||||||
|
get() = sp.getBoolean("watch_enabled", false)
|
||||||
|
set(v) = sp.edit().putBoolean("watch_enabled", v).apply()
|
||||||
|
|
||||||
|
fun signOut() = sp.edit().clear().apply()
|
||||||
|
|
||||||
|
/** What this device last synced for one database. */
|
||||||
|
data class FileState(
|
||||||
|
var name: String = "",
|
||||||
|
var file: String = "",
|
||||||
|
var version: Long = 0,
|
||||||
|
var sha256: String = "",
|
||||||
|
var conflictMarker: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
fun state(dbId: String): FileState {
|
||||||
|
val raw = sp.getString("state_$dbId", null) ?: return FileState()
|
||||||
|
val o = runCatching { JSONObject(raw) }.getOrNull() ?: return FileState()
|
||||||
|
return FileState(
|
||||||
|
o.optString("name"),
|
||||||
|
o.optString("file"),
|
||||||
|
o.optLong("version"),
|
||||||
|
o.optString("sha256"),
|
||||||
|
o.optString("conflict_marker"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun putState(dbId: String, st: FileState) {
|
||||||
|
val o = JSONObject()
|
||||||
|
.put("name", st.name).put("file", st.file)
|
||||||
|
.put("version", st.version).put("sha256", st.sha256)
|
||||||
|
.put("conflict_marker", st.conflictMarker)
|
||||||
|
sp.edit().putString("state_$dbId", o.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forgetState(dbId: String) = sp.edit().remove("state_$dbId").apply()
|
||||||
|
|
||||||
|
/** Known database ids, so files of unassigned databases stop being tracked. */
|
||||||
|
var trackedIds: Set<String>
|
||||||
|
get() = sp.getStringSet("tracked", emptySet()) ?: emptySet()
|
||||||
|
set(v) = sp.edit().putStringSet("tracked", v).apply()
|
||||||
|
|
||||||
|
/** The last status line shown on the main screen, per database. */
|
||||||
|
fun putStatus(dbId: String, text: String) = sp.edit().putString("status_$dbId", text).apply()
|
||||||
|
|
||||||
|
fun status(dbId: String): String = sp.getString("status_$dbId", "") ?: ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package cc.tiredsysadmin.android.keeporg
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the server's event stream open so a change made by a colleague
|
||||||
|
* reaches this device without the user opening the app. A notification is
|
||||||
|
* required for that, and it doubles as the switch the user can see.
|
||||||
|
*/
|
||||||
|
class SyncService : Service() {
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var running = false
|
||||||
|
private var worker: Thread? = null
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (!Prefs(this).signedIn) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
startForeground(NOTIFICATION_ID, notification("Watching for changes"))
|
||||||
|
Syncer.request(this, "service started")
|
||||||
|
if (!running) {
|
||||||
|
running = true
|
||||||
|
worker = Thread({ listen() }, "keeporg-events").apply { isDaemon = true; start() }
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
running = false
|
||||||
|
worker?.interrupt()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconnects to the event stream for as long as the service lives. */
|
||||||
|
private fun listen() {
|
||||||
|
var backoff = 1000L
|
||||||
|
while (running) {
|
||||||
|
val api = Syncer.apiOf(this)
|
||||||
|
if (api == null) {
|
||||||
|
stopSelf()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
api.events { ev ->
|
||||||
|
backoff = 1000L
|
||||||
|
Log.d(TAG, "event ${ev.optString("type")} ${ev.optString("name")}")
|
||||||
|
Syncer.request(this, "server event")
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
if (!running) return
|
||||||
|
Log.d(TAG, "event stream: ${e.message}")
|
||||||
|
} catch (e: InterruptedException) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A dropped stream only delays a reconcile, so retry gently and
|
||||||
|
// reconcile once on the way past in case something was missed.
|
||||||
|
Syncer.request(this, "stream reconnect")
|
||||||
|
try {
|
||||||
|
Thread.sleep(backoff)
|
||||||
|
} catch (e: InterruptedException) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (backoff < 60_000L) backoff *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notification(text: String): Notification {
|
||||||
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL, getString(R.string.sync_channel), NotificationManager.IMPORTANCE_LOW)
|
||||||
|
)
|
||||||
|
val open = PendingIntent.getActivity(
|
||||||
|
this, 0, Intent(this, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
return Notification.Builder(this, CHANNEL)
|
||||||
|
.setContentTitle(getString(R.string.app_name))
|
||||||
|
.setContentText(text)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||||
|
.setContentIntent(open)
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "KeepOrgService"
|
||||||
|
private const val CHANNEL = "sync"
|
||||||
|
private const val NOTIFICATION_ID = 1
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
context.startForegroundService(Intent(context, SyncService::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
context.stopService(Intent(context, SyncService::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package cc.tiredsysadmin.android.keeporg
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place a synchronization pass is run. Work is serialized on a single
|
||||||
|
* thread, and a request that arrives while a pass is already queued is
|
||||||
|
* folded into it, so a burst of saves costs one reconcile.
|
||||||
|
*/
|
||||||
|
object Syncer {
|
||||||
|
|
||||||
|
private const val TAG = "KeepOrgSyncer"
|
||||||
|
private val pool = Executors.newSingleThreadExecutor()
|
||||||
|
private val queued = AtomicBoolean(false)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var lastError: String? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Builds an API client from the stored session, or null when signed out. */
|
||||||
|
fun apiOf(context: Context): Api? {
|
||||||
|
val prefs = Prefs(context)
|
||||||
|
if (!prefs.signedIn) return null
|
||||||
|
return Api(prefs.serverUrl, prefs.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mirrorOf(context: Context): Mirror? {
|
||||||
|
val api = apiOf(context) ?: return null
|
||||||
|
return Mirror(context.applicationContext, api, Prefs(context))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queues a pass. [done] runs on the worker thread when it finishes. */
|
||||||
|
fun request(context: Context, reason: String, done: ((Result<Unit>) -> Unit)? = null) {
|
||||||
|
val app = context.applicationContext
|
||||||
|
if (done == null && !queued.compareAndSet(false, true)) return
|
||||||
|
pool.execute {
|
||||||
|
if (done == null) queued.set(false)
|
||||||
|
Log.d(TAG, "sync: $reason")
|
||||||
|
val mirror = mirrorOf(app)
|
||||||
|
val result = mirror?.syncAll() ?: Result.failure(IllegalStateException("signed out"))
|
||||||
|
lastError = result.exceptionOrNull()?.message
|
||||||
|
done?.invoke(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package cc.tiredsysadmin.android.keeporg
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.util.TypedValue
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two screens are built in code. The app has no third-party UI library
|
||||||
|
* and needs no layout files, which keeps the APK and the build minimal.
|
||||||
|
*/
|
||||||
|
internal object Ui {
|
||||||
|
|
||||||
|
fun dp(context: Context, value: Int): Int =
|
||||||
|
TypedValue.applyDimension(
|
||||||
|
TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), context.resources.displayMetrics
|
||||||
|
).toInt()
|
||||||
|
|
||||||
|
fun column(context: Context): LinearLayout = LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val pad = dp(context, 20)
|
||||||
|
setPadding(pad, pad, pad, pad)
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun heading(context: Context, text: String): TextView = TextView(context).apply {
|
||||||
|
this.text = text
|
||||||
|
textSize = 20f
|
||||||
|
setTypeface(typeface, Typeface.BOLD)
|
||||||
|
setPadding(0, 0, 0, dp(context, 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun label(context: Context, text: String): TextView = TextView(context).apply {
|
||||||
|
this.text = text
|
||||||
|
textSize = 13f
|
||||||
|
alpha = 0.7f
|
||||||
|
setPadding(0, dp(context, 12), 0, dp(context, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun body(context: Context, text: String = ""): TextView = TextView(context).apply {
|
||||||
|
this.text = text
|
||||||
|
textSize = 14f
|
||||||
|
setTypeface(Typeface.MONOSPACE)
|
||||||
|
setPadding(0, dp(context, 8), 0, dp(context, 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun field(context: Context, hint: String, inputType: Int): EditText = EditText(context).apply {
|
||||||
|
this.hint = hint
|
||||||
|
this.inputType = inputType
|
||||||
|
}
|
||||||
|
|
||||||
|
fun button(context: Context, text: String, onClick: () -> Unit): Button = Button(context).apply {
|
||||||
|
this.text = text
|
||||||
|
setOnClickListener { onClick() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs work off the main thread and delivers the result back on it. */
|
||||||
|
fun <T> background(activity: Activity, work: () -> T, then: (Result<T>) -> Unit) {
|
||||||
|
Thread {
|
||||||
|
val result = runCatching(work)
|
||||||
|
activity.runOnUiThread { if (!activity.isFinishing) then(result) }
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- A key over a pair of sync arrows, drawn as a single vector path set. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M54,30a12,12 0 1,0 -11.3,16h4.3v6h6v6h6v-9.5a12,12 0 0,0 -5,-18.5zM54,36a4,4 0 1,1 0,8a4,4 0 0,1 0,-8z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M34,66h34l-6,-6h8l10,10l-10,10h-8l6,-6h-34z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M74,86h-34l6,6h-8l-10,-10l10,-10h8l-6,6h34z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/icon_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="icon_background">#2D6A4F</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">KeepOrg</string>
|
||||||
|
<string name="sign_in">Sign in</string>
|
||||||
|
<string name="provider_root">KeepOrg databases</string>
|
||||||
|
<string name="sync_channel">Synchronization</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Root build file. The application has no third-party dependencies: it talks
|
||||||
|
// to the server with java.net.HttpURLConnection and draws its two screens in
|
||||||
|
// code, which keeps the APK small and an F-Droid build reproducible.
|
||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.7.3" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2g
|
||||||
|
android.useAndroidX=false
|
||||||
|
kotlin.code.style=official
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# F-Droid metadata for the private repository.
|
||||||
|
# Publish with: make android-publish (copies this file to the metadata
|
||||||
|
# directory and the unsigned APK to the unsigned directory).
|
||||||
|
Categories:
|
||||||
|
- Security
|
||||||
|
License: AGPL-3.0-or-later
|
||||||
|
AuthorName: tiredsysadmin
|
||||||
|
SourceCode: https://garage.rcmd.space/keeporg
|
||||||
|
IssueTracker: https://garage.rcmd.space/keeporg/issues
|
||||||
|
|
||||||
|
Name: KeepOrg
|
||||||
|
Summary: Collaborative sync adapter for KeePass databases
|
||||||
|
Description: |-
|
||||||
|
KeepOrg mirrors the KDBX databases a KeepOrg server has assigned to you and
|
||||||
|
publishes them to every file picker on the device, so KeePassDX or
|
||||||
|
Keepass2Android opens them as ordinary files.
|
||||||
|
|
||||||
|
Each save is pushed to the server, and changes other people push are applied
|
||||||
|
here. When two people save at once, the database's merge strategy decides
|
||||||
|
the outcome; the losing copy is always kept beside the file so nothing is
|
||||||
|
lost silently.
|
||||||
|
|
||||||
|
The app never opens a database and never sees a master password. It moves
|
||||||
|
encrypted bytes and nothing else.
|
||||||
|
|
||||||
|
Sign in with a password and the server's built-in time-based second factor,
|
||||||
|
or approve the device in a browser, which is also how single sign-on works.
|
||||||
|
|
||||||
|
RepoType: git
|
||||||
|
Repo: https://garage.rcmd.space/keeporg.git
|
||||||
|
|
||||||
|
Builds:
|
||||||
|
- versionName: 0.1.0
|
||||||
|
versionCode: 1
|
||||||
|
commit: v0.1.0-android
|
||||||
|
subdir: android/app
|
||||||
|
gradle:
|
||||||
|
- yes
|
||||||
|
output: android/app/build/outputs/apk/release/app-release-unsigned.apk
|
||||||
|
|
||||||
|
AutoUpdateMode: None
|
||||||
|
UpdateCheckMode: Tags
|
||||||
|
CurrentVersion: 0.1.0
|
||||||
|
CurrentVersionCode: 1
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootProject.name = "KeepOrg"
|
||||||
|
include(":app")
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
# KeepOrg: developer handover
|
||||||
|
|
||||||
|
KeepOrg makes a KeePass database collaborative without changing KeePass. A
|
||||||
|
server holds many KDBX files and arbitrates who wrote last; clients present
|
||||||
|
each assigned database as an ordinary `.kdbx` file that any KeePass
|
||||||
|
application can open, and synchronize it on every save.
|
||||||
|
|
||||||
|
The server never opens a database. It moves encrypted bytes and metadata
|
||||||
|
about them. Master passwords and key files stay on the clients, so a
|
||||||
|
compromised server leaks the ciphertext and the group structure, nothing else.
|
||||||
|
|
||||||
|
## Repository map
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/keeporgd/ server entry point: flags, config file, bootstrap
|
||||||
|
cmd/keeporg/ desktop client: login, run, sync, status, administration
|
||||||
|
internal/store/ domain model, JSON metadata store, ACL resolution
|
||||||
|
internal/blob/ versioned KDBX payload storage on disk
|
||||||
|
internal/server/ HTTP surface: auth, OIDC, sync, admin, SSE hub, pages
|
||||||
|
internal/client/ shared client engine: API wrapper, merge policy, mirror
|
||||||
|
internal/secret/ password verifiers, tokens, identifiers
|
||||||
|
internal/totp/ RFC 6238 second factor
|
||||||
|
android/ Android client: DocumentsProvider, sync service, UI
|
||||||
|
packaging/ deb, macOS bundle and DMG, Windows zip, systemd units
|
||||||
|
scripts/ package builders, version bump, changelog
|
||||||
|
versions.mk the one place every platform's version lives
|
||||||
|
Makefile the only entry point for building and publishing
|
||||||
|
```
|
||||||
|
|
||||||
|
## The three programs
|
||||||
|
|
||||||
|
### Server (`keeporgd`)
|
||||||
|
|
||||||
|
One process, one data directory, no database engine:
|
||||||
|
|
||||||
|
- `state.json` holds users, groups, memberships, databases, grants, invites,
|
||||||
|
sessions and pending device logins.
|
||||||
|
- `blobs/<database-id>/<version>.kdbx` holds the payloads, keeping the last
|
||||||
|
20 versions of each database.
|
||||||
|
|
||||||
|
`internal/store.Store` is the only writer of `state.json`. Every mutation
|
||||||
|
runs through `Update(func(*State) error)`, which applies the change to a
|
||||||
|
private copy, writes it with `write` + `fsync` + `rename`, and only then
|
||||||
|
publishes it. A failed mutation changes nothing, and a crash leaves either
|
||||||
|
the old document or the new one.
|
||||||
|
|
||||||
|
Reads go through `Read(func(*State))` under a read lock. Access decisions are
|
||||||
|
pure functions over `*State` in `internal/store/acl.go`, which is why they
|
||||||
|
are cheap enough to call per request and easy to test exhaustively.
|
||||||
|
|
||||||
|
### Desktop client (`keeporg`)
|
||||||
|
|
||||||
|
`keeporg run` keeps a mirror directory (default `~/KeepOrg`) in step with the
|
||||||
|
server:
|
||||||
|
|
||||||
|
- a rescan every two seconds notices that a KeePass application saved a file;
|
||||||
|
- the server's event stream delivers other people's pushes immediately;
|
||||||
|
- `.keeporg-state.json` in the mirror records what this device last agreed
|
||||||
|
with the server for each database.
|
||||||
|
|
||||||
|
All of the synchronization policy is the pure `decide()` table in
|
||||||
|
`internal/client/decide.go`. Everything else in the mirror applies the action
|
||||||
|
it returns. To change behaviour, change that table and its test.
|
||||||
|
|
||||||
|
### Android client
|
||||||
|
|
||||||
|
`KeepOrgProvider` is a `DocumentsProvider`, so the mirrored files appear in
|
||||||
|
every file picker under "KeepOrg" and KeePassDX or Keepass2Android opens them
|
||||||
|
as ordinary documents. When such an application closes a file it had open for
|
||||||
|
writing, the provider triggers a synchronization pass; that is how a save
|
||||||
|
reaches the server. `SyncService` is a foreground service that holds the
|
||||||
|
event stream open so colleagues' changes arrive without opening the app.
|
||||||
|
|
||||||
|
`Mirror.decide` is a transcription of the Go decision table. **Keep the two in
|
||||||
|
step**: a change to one belongs in the other in the same commit.
|
||||||
|
|
||||||
|
## Access control
|
||||||
|
|
||||||
|
Groups form one tree. Two rules decide everything:
|
||||||
|
|
||||||
|
1. **A grant on a group reaches every member below it.** A grant on
|
||||||
|
`Company` applies to a member of `Company/Engineering/Backend`, because
|
||||||
|
the member's effective group set is their own groups plus all ancestors.
|
||||||
|
2. **An administrator of a group administers everything below it.** A
|
||||||
|
`Company` admin administers `Backend`; a `Sales` admin does not.
|
||||||
|
|
||||||
|
A database is owned by one group. Administering that group means `admin` on
|
||||||
|
the database: renaming it, changing its merge strategy, granting other groups
|
||||||
|
access and deleting it. Other groups reach it only through explicit grants,
|
||||||
|
with `read`, `write` or `admin`. A user's permission on a database is the
|
||||||
|
strongest grant that reaches them. Server administrators administer
|
||||||
|
everything.
|
||||||
|
|
||||||
|
Nothing is browsable. A user who has no permission on a database cannot see
|
||||||
|
that it exists: the blob endpoint answers `404`, not `403`.
|
||||||
|
|
||||||
|
## Synchronization protocol
|
||||||
|
|
||||||
|
Full endpoint reference: [PROTOCOL.md](PROTOCOL.md). The part that matters:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/databases what is assigned, with version and digest
|
||||||
|
GET /api/v1/databases/{id}/blob bytes, ETag: "<version>"
|
||||||
|
PUT /api/v1/databases/{id}/blob If-Match: "<version this edit was based on>"
|
||||||
|
GET /api/v1/events SSE stream of db-updated notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
A push always states the version it was based on. If that is still current,
|
||||||
|
the server stores version+1, publishes `db-updated` to everyone who can read
|
||||||
|
the database, and prunes old versions. If it is not current, the stored merge
|
||||||
|
strategy decides, and the rejection carries both the current version and the
|
||||||
|
strategy so the client can resolve without another round trip.
|
||||||
|
|
||||||
|
Pushing bytes the server already holds is a no-op that answers
|
||||||
|
`{"unchanged": true}`: a save that changed nothing does not burn a version
|
||||||
|
and does not wake other clients.
|
||||||
|
|
||||||
|
### Merge strategies
|
||||||
|
|
||||||
|
Set per database, changed by an administrator of the owning group
|
||||||
|
(`keeporg db -id <id> -strategy <name>`).
|
||||||
|
|
||||||
|
| Strategy | Server behaviour on a stale push | Client behaviour |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `server-wins` (default) | rejects with `409 conflict` | keeps the local save as `<name>.conflict-<timestamp>.kdbx`, then writes the server copy |
|
||||||
|
| `client-wins` | accepts a retry carrying `If-Match: *` | retries with force; the server copy is overwritten and kept in history |
|
||||||
|
| `manual` | rejects, and refuses force | writes the server copy as `<name>.server-v<n>.kdbx` beside the file, touches nothing, reports a conflict once |
|
||||||
|
|
||||||
|
A losing save is never discarded: it is always written somewhere the user can
|
||||||
|
find it. A read-only user who edits their copy gets the same treatment, with
|
||||||
|
the server copy restored.
|
||||||
|
|
||||||
|
**There is no entry-level merge.** The server cannot perform one, because it
|
||||||
|
cannot decrypt a database. A true three-way merge belongs in a client that
|
||||||
|
already holds the master password; the strategy field is the hook for it, and
|
||||||
|
`manual` is the honest behaviour until then. KeePassXC's own "Merge from
|
||||||
|
database" on the two files is the current answer.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Three ways in, one kind of credential out: an opaque bearer token stored in
|
||||||
|
`state.json` with an expiry (90 days by default).
|
||||||
|
|
||||||
|
- **Password** (`POST /api/v1/auth/login`), with the built-in TOTP second
|
||||||
|
factor when the account has enrolled one. The server answers
|
||||||
|
`totp_required` so a client can ask for the code without guessing.
|
||||||
|
- **Invite link.** A group administrator creates one; the invitee opens it in
|
||||||
|
a browser, picks a username and password, and lands in the invite's group
|
||||||
|
with the invite's role.
|
||||||
|
- **Browser-approved device login**, which is also how OIDC works. The client
|
||||||
|
gets a short user code, the user approves it in a browser with a password
|
||||||
|
or with single sign-on, and the client polls until a token appears. The
|
||||||
|
same flow serves a headless desktop and an Android phone.
|
||||||
|
|
||||||
|
OIDC is implemented against the discovery document with the standard library
|
||||||
|
only. The server is a confidential client: it exchanges the authorization
|
||||||
|
code over TLS with the token endpoint and reads the identity from the
|
||||||
|
userinfo endpoint, so there is no local JWT verification to get wrong. An
|
||||||
|
identity links to an existing account by `preferred_username` or `email`, or
|
||||||
|
provisions one when `oidc.auto_create` is set.
|
||||||
|
|
||||||
|
## Decisions worth knowing
|
||||||
|
|
||||||
|
**No third-party Go dependencies, no cgo.** The whole server and desktop
|
||||||
|
client build with `CGO_ENABLED=0` from the standard library: SSE instead of a
|
||||||
|
WebSocket library, `crypto/pbkdf2` (new in Go 1.24) instead of an Argon2
|
||||||
|
package, 60 lines of HMAC for TOTP, `html/template` for the three pages.
|
||||||
|
Cross-compiling to five platforms is then one `go build` each, and there is
|
||||||
|
no dependency tree to audit for a program that guards passwords.
|
||||||
|
|
||||||
|
**A JSON document instead of SQLite.** The metadata is hundreds of records,
|
||||||
|
never thousands: users, groups, grants. One writer with atomic rename gives
|
||||||
|
durability and consistency without cgo or a migration story, and an operator
|
||||||
|
can read and back up the whole server state with `cat`. The cost is a single
|
||||||
|
node and a full rewrite per mutation. If this ever needs several servers or
|
||||||
|
tens of thousands of users, replace `internal/store` — the rest of the code
|
||||||
|
only touches it through `Read`, `Update` and the ACL functions.
|
||||||
|
|
||||||
|
**A mirror directory instead of FUSE.** The requirement is that a KeePass
|
||||||
|
application sees a regular `.kdbx` file. A real file in a real directory is
|
||||||
|
that, on Linux, macOS, Windows and BSD alike, with no kernel module, no
|
||||||
|
WinFsp or macFUSE dependency, no cgo and nothing to break on an OS upgrade.
|
||||||
|
Local writes are atomic (`write` + `rename`), so an application never reads
|
||||||
|
half a database. The price is that synchronization happens on a save rather
|
||||||
|
than on a `write()` syscall, which is exactly the granularity KDBX has
|
||||||
|
anyway.
|
||||||
|
|
||||||
|
**SSE instead of WebSocket.** The channel only carries server-to-client
|
||||||
|
notifications. SSE is a `GET` that survives proxies, reconnects by itself and
|
||||||
|
needs no library on any of the three clients. A subscriber that falls behind
|
||||||
|
is dropped rather than waited for; it reconnects and reconciles from scratch,
|
||||||
|
because every notification only ever triggers a full reconcile.
|
||||||
|
|
||||||
|
**Versions, not just a digest.** Keeping the last 20 blobs makes a rejected
|
||||||
|
push recoverable, makes "who broke the database" answerable, and costs a few
|
||||||
|
megabytes.
|
||||||
|
|
||||||
|
## Operating it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make deb # keeporg and keeporgd packages for this arch
|
||||||
|
sudo dpkg -i dist/keeporgd_*.deb
|
||||||
|
sudo nano /etc/keeporg/server.json
|
||||||
|
sudo systemctl enable --now keeporgd
|
||||||
|
sudo journalctl -u keeporgd # the first administrator's password is logged once
|
||||||
|
```
|
||||||
|
|
||||||
|
On first start with an empty state the server creates the `admin` account and
|
||||||
|
an `everyone` group, and logs the generated password once. Set
|
||||||
|
`KEEPORG_ADMIN_PASSWORD` to choose it instead.
|
||||||
|
|
||||||
|
`/etc/keeporg/server.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"addr": ":8765",
|
||||||
|
"data_dir": "/var/lib/keeporg",
|
||||||
|
"external_url": "https://keeporg.example.com",
|
||||||
|
"session_ttl_hours": 2160,
|
||||||
|
"max_db_size_mb": 32,
|
||||||
|
"oidc": {
|
||||||
|
"issuer": "https://id.example.com/realms/main",
|
||||||
|
"client_id": "keeporg",
|
||||||
|
"client_secret": "...",
|
||||||
|
"scopes": "openid profile email",
|
||||||
|
"auto_create": false,
|
||||||
|
"default_group_id": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`external_url` must be what a browser sees: invite links, device approval
|
||||||
|
pages and the OIDC redirect URI are built from it. Terminate TLS in front of
|
||||||
|
the server, or set `tls_cert` and `tls_key`.
|
||||||
|
|
||||||
|
Back up `/var/lib/keeporg`. It is the whole server: `state.json` plus the
|
||||||
|
blobs. There is nothing else to dump.
|
||||||
|
|
||||||
|
### Setting up a team
|
||||||
|
|
||||||
|
```sh
|
||||||
|
keeporg login -server https://keeporg.example.com -user admin
|
||||||
|
keeporg group -create engineering -parent <everyone-id>
|
||||||
|
keeporg db -create "Engineering" -group <engineering-id>
|
||||||
|
keeporg invite -group <engineering-id> -role member -uses 5 -hours 72
|
||||||
|
keeporg grant -group <readers-id> -db <db-id> -perm read
|
||||||
|
keeporg db -id <db-id> -strategy manual
|
||||||
|
```
|
||||||
|
|
||||||
|
A new database has no bytes until someone saves one. Create the KDBX in your
|
||||||
|
KeePass application under the exact name the server shows, inside the mirror
|
||||||
|
directory; the client adopts it and pushes it as version 1.
|
||||||
|
|
||||||
|
## Versioning and publishing
|
||||||
|
|
||||||
|
`versions.mk` is the only place a version lives, one per platform plus the
|
||||||
|
Android `versionCode`. Binaries are stamped at build time through
|
||||||
|
`-ldflags -X main.Version=...`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make bump PLATFORM=android PART=minor # also increments versionCode
|
||||||
|
make changelog MSG="what changed" # prepends to the Debian changelog
|
||||||
|
```
|
||||||
|
|
||||||
|
Application identifiers follow `cc.tiredsysadmin.<os>.<app>`:
|
||||||
|
`cc.tiredsysadmin.android.keeporg`, `.macos.keeporg`, `.linux.keeporg`,
|
||||||
|
`.windows.keeporg`, `.bsd.keeporg`, and `.linux.keeporgd` for the server
|
||||||
|
package.
|
||||||
|
|
||||||
|
Desktop and server artifacts stay local (`dist/`). Android publishing is
|
||||||
|
manual, never automatic:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make android # unsigned APK, named cc.tiredsysadmin.android.keeporg_<code>.apk
|
||||||
|
make android-publish # scp to the private F-Droid unsigned and metadata directories
|
||||||
|
```
|
||||||
|
|
||||||
|
Then sign and index on the repository host (`fdroid publish && fdroid update`).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make check # gofmt, go vet, go test ./...
|
||||||
|
make test-race # the same suite under the race detector
|
||||||
|
```
|
||||||
|
|
||||||
|
What the suite actually covers:
|
||||||
|
|
||||||
|
- `internal/totp` against the RFC 6238 vectors.
|
||||||
|
- `internal/store` for grant inheritance, sibling isolation, admin
|
||||||
|
inheritance, cycle safety, update rollback and expiry.
|
||||||
|
- `internal/server` for every push verdict as a table, and an end-to-end HTTP
|
||||||
|
pass over login, stale push, forced push, idempotent push, read-only
|
||||||
|
grants, invite redemption and reuse, TOTP enrolment, device login and the
|
||||||
|
event stream.
|
||||||
|
- `internal/client` for the decision table, and for the mirror driven against
|
||||||
|
a real in-process server: adopting a local file, following a remote push,
|
||||||
|
server-wins, client-wins, manual, read-only restore, server-side rename,
|
||||||
|
and an update arriving over the event stream.
|
||||||
|
|
||||||
|
## Known limits and the next things to do
|
||||||
|
|
||||||
|
- **One server node.** The JSON store is single-writer by design. Horizontal
|
||||||
|
scale means replacing `internal/store`.
|
||||||
|
- **No entry-level merge.** See the strategies section. The pluggable place
|
||||||
|
is the client, which holds the key.
|
||||||
|
- **The Android module has not been compiled in this environment** (no JDK or
|
||||||
|
Android SDK was available). The Gradle wrapper is deliberately not
|
||||||
|
committed; run `gradle wrapper --gradle-version 8.9` once, then
|
||||||
|
`make android`. Expect to fix small things on the first build.
|
||||||
|
- **No key-file or Yubikey handling**, and none needed: those live in the
|
||||||
|
KeePass application.
|
||||||
|
- **Sessions are not listable or revocable individually** beyond
|
||||||
|
`keeporg logout` on the device itself. Adding `GET`/`DELETE
|
||||||
|
/api/v1/auth/sessions` is a small, well-bounded job.
|
||||||
|
- **Android does not restart its watcher at boot.** Add a
|
||||||
|
`RECEIVE_BOOT_COMPLETED` receiver if that matters more than the permission
|
||||||
|
it costs.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# KeepOrg HTTP API
|
||||||
|
|
||||||
|
Every `/api/` endpoint takes and returns JSON and needs
|
||||||
|
`Authorization: Bearer <token>`, except the login, device and invite
|
||||||
|
endpoints. Errors carry `{"code": "...", "error": "..."}`; `code` is stable
|
||||||
|
and meant to be matched on, `error` is for humans.
|
||||||
|
|
||||||
|
Three endpoints serve HTML, because a browser is the right place for them:
|
||||||
|
`/device`, `/invite/{token}` and `/oidc/callback`.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
| Method | Path | Body | Answer |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| POST | `/api/v1/auth/login` | `{username, password, totp, label}` | `{token, expires_at, user}`; `401 totp_required` when a second factor is enrolled and missing, `401 totp_invalid`, `401 bad_credentials` |
|
||||||
|
| POST | `/api/v1/auth/logout` | – | revokes the calling token |
|
||||||
|
| GET | `/api/v1/auth/whoami` | – | the account and its group memberships |
|
||||||
|
| POST | `/api/v1/auth/totp/enroll` | – | `{secret, uri}`; not active until confirmed |
|
||||||
|
| POST | `/api/v1/auth/totp/confirm` | `{code}` | `{totp_enabled: true}` |
|
||||||
|
| POST | `/api/v1/auth/device/start` | `{label}` | `{device_code, user_code, verification_url, interval, expires_in}` |
|
||||||
|
| POST | `/api/v1/auth/device/poll` | `{device_code}` | `202 {status: "pending"}`, then `200 {token, ...}` once; `410 expired` |
|
||||||
|
|
||||||
|
A device code is single use: the first successful poll consumes it.
|
||||||
|
|
||||||
|
## Synchronization
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/v1/databases` | `{databases: [{id, name, perm, merge_strategy, version, sha256, size, updated_at, updated_by}]}` |
|
||||||
|
| GET | `/api/v1/databases/{id}/blob` | bytes; `ETag: "<version>"`, `X-KeepOrg-SHA256`; `204` when the database has no content yet; `?version=<n>` reads a stored older version |
|
||||||
|
| PUT | `/api/v1/databases/{id}/blob` | body is the KDBX file. `If-Match` is required |
|
||||||
|
| GET | `/api/v1/events` | SSE. Accepts `?access_token=` because an event stream cannot always set headers |
|
||||||
|
|
||||||
|
### PUT semantics
|
||||||
|
|
||||||
|
`If-Match` carries the version the edit was based on, or `*` to force.
|
||||||
|
|
||||||
|
| Condition | Status | Body |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `If-Match` missing | `428 precondition_required` | – |
|
||||||
|
| version matches, bytes differ | `200` | `{version, sha256, unchanged: false}` |
|
||||||
|
| version matches, bytes identical | `200` | `{version, sha256, unchanged: true}` |
|
||||||
|
| version stale | `409 conflict` | `{current_version, merge_strategy}`, plus `ETag` and `X-KeepOrg-Strategy` |
|
||||||
|
| `If-Match: *`, strategy is `client-wins` | `200` | the server copy is replaced |
|
||||||
|
| `If-Match: *`, any other strategy | `409 force_not_allowed` | – |
|
||||||
|
| read-only access | `403 forbidden` | – |
|
||||||
|
| no access | `404 not_found` | a database you cannot read does not exist to you |
|
||||||
|
| larger than `max_db_size_mb` | `413 too_large` | – |
|
||||||
|
|
||||||
|
`X-KeepOrg-SHA256` on a `PUT` is optional; when present it must match the
|
||||||
|
body or the request is rejected with `400 checksum_mismatch`.
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
```
|
||||||
|
event: db-updated
|
||||||
|
data: {"type":"db-updated","database_id":"...","name":"Team","version":7,"sha256":"...","by":"alice"}
|
||||||
|
|
||||||
|
event: db-list-changed
|
||||||
|
data: {"type":"db-list-changed","database_id":"..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
`db-updated` follows an accepted push; `db-list-changed` follows a grant,
|
||||||
|
rename, strategy change or deletion. A comment line (`: keepalive`) arrives
|
||||||
|
every 25 seconds. Reconnect on any error and reconcile fully; events are
|
||||||
|
notifications, never state.
|
||||||
|
|
||||||
|
## Administration
|
||||||
|
|
||||||
|
Permission needed is in the last column. "Group admin" means administering
|
||||||
|
the group named or any group above it.
|
||||||
|
|
||||||
|
| Method | Path | Body / query | Needs |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/v1/groups` | – | any account; lists what you can see |
|
||||||
|
| POST | `/api/v1/groups` | `{name, parent_id}` | group admin of the parent; server admin for a root group |
|
||||||
|
| DELETE | `/api/v1/groups/{id}` | – | group admin; refuses while it has children, members or databases |
|
||||||
|
| POST | `/api/v1/groups/{id}/members` | `{username or user_id, role}` | group admin |
|
||||||
|
| DELETE | `/api/v1/groups/{id}/members/{user}` | – | group admin |
|
||||||
|
| POST | `/api/v1/databases` | `{name, owner_group_id, merge_strategy}` | group admin of the owner |
|
||||||
|
| PATCH | `/api/v1/databases/{id}` | `{name?, merge_strategy?}` | database admin |
|
||||||
|
| DELETE | `/api/v1/databases/{id}` | – | database admin; deletes every stored version |
|
||||||
|
| POST | `/api/v1/grants` | `{group_id, database_id, perm}` | database admin |
|
||||||
|
| DELETE | `/api/v1/grants` | `?group_id=&database_id=` | database admin |
|
||||||
|
| POST | `/api/v1/invites` | `{group_id, role, ttl_hours, max_uses}` | group admin |
|
||||||
|
| GET | `/api/v1/invites` | – | your own invites, and those of groups you administer |
|
||||||
|
| GET | `/api/v1/users` | – | yourself, plus members of groups you administer |
|
||||||
|
| GET | `/healthz` | – | unauthenticated liveness |
|
||||||
|
|
||||||
|
`merge_strategy` is one of `server-wins`, `client-wins`, `manual`. `perm` is
|
||||||
|
one of `read`, `write`, `admin`. `role` is `member` or `admin`.
|
||||||
+24
-24
@@ -113,10 +113,10 @@ func (s *Server) routes() {
|
|||||||
|
|
||||||
// Authentication.
|
// Authentication.
|
||||||
m.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
|
m.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
|
||||||
m.HandleFunc("POST /api/v1/auth/logout", s.authed(store.PermNone, s.handleLogout))
|
m.HandleFunc("POST /api/v1/auth/logout", s.authed(s.handleLogout))
|
||||||
m.HandleFunc("GET /api/v1/auth/whoami", s.authed(store.PermNone, s.handleWhoami))
|
m.HandleFunc("GET /api/v1/auth/whoami", s.authed(s.handleWhoami))
|
||||||
m.HandleFunc("POST /api/v1/auth/totp/enroll", s.authed(store.PermNone, s.handleTOTPEnroll))
|
m.HandleFunc("POST /api/v1/auth/totp/enroll", s.authed(s.handleTOTPEnroll))
|
||||||
m.HandleFunc("POST /api/v1/auth/totp/confirm", s.authed(store.PermNone, s.handleTOTPConfirm))
|
m.HandleFunc("POST /api/v1/auth/totp/confirm", s.authed(s.handleTOTPConfirm))
|
||||||
m.HandleFunc("POST /api/v1/auth/device/start", s.handleDeviceStart)
|
m.HandleFunc("POST /api/v1/auth/device/start", s.handleDeviceStart)
|
||||||
m.HandleFunc("POST /api/v1/auth/device/poll", s.handleDevicePoll)
|
m.HandleFunc("POST /api/v1/auth/device/poll", s.handleDevicePoll)
|
||||||
|
|
||||||
@@ -129,25 +129,25 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("GET /oidc/callback", s.handleOIDCCallback)
|
m.HandleFunc("GET /oidc/callback", s.handleOIDCCallback)
|
||||||
|
|
||||||
// Synchronization.
|
// Synchronization.
|
||||||
m.HandleFunc("GET /api/v1/databases", s.authed(store.PermNone, s.handleListDatabases))
|
m.HandleFunc("GET /api/v1/databases", s.authed(s.handleListDatabases))
|
||||||
m.HandleFunc("GET /api/v1/databases/{id}/blob", s.authed(store.PermNone, s.handleGetBlob))
|
m.HandleFunc("GET /api/v1/databases/{id}/blob", s.authed(s.handleGetBlob))
|
||||||
m.HandleFunc("PUT /api/v1/databases/{id}/blob", s.authed(store.PermNone, s.handlePutBlob))
|
m.HandleFunc("PUT /api/v1/databases/{id}/blob", s.authed(s.handlePutBlob))
|
||||||
m.HandleFunc("GET /api/v1/events", s.authed(store.PermNone, s.handleEvents))
|
m.HandleFunc("GET /api/v1/events", s.authed(s.handleEvents))
|
||||||
|
|
||||||
// Administration.
|
// Administration.
|
||||||
m.HandleFunc("POST /api/v1/databases", s.authed(store.PermNone, s.handleCreateDatabase))
|
m.HandleFunc("POST /api/v1/databases", s.authed(s.handleCreateDatabase))
|
||||||
m.HandleFunc("PATCH /api/v1/databases/{id}", s.authed(store.PermNone, s.handlePatchDatabase))
|
m.HandleFunc("PATCH /api/v1/databases/{id}", s.authed(s.handlePatchDatabase))
|
||||||
m.HandleFunc("DELETE /api/v1/databases/{id}", s.authed(store.PermNone, s.handleDeleteDatabase))
|
m.HandleFunc("DELETE /api/v1/databases/{id}", s.authed(s.handleDeleteDatabase))
|
||||||
m.HandleFunc("GET /api/v1/groups", s.authed(store.PermNone, s.handleListGroups))
|
m.HandleFunc("GET /api/v1/groups", s.authed(s.handleListGroups))
|
||||||
m.HandleFunc("POST /api/v1/groups", s.authed(store.PermNone, s.handleCreateGroup))
|
m.HandleFunc("POST /api/v1/groups", s.authed(s.handleCreateGroup))
|
||||||
m.HandleFunc("DELETE /api/v1/groups/{id}", s.authed(store.PermNone, s.handleDeleteGroup))
|
m.HandleFunc("DELETE /api/v1/groups/{id}", s.authed(s.handleDeleteGroup))
|
||||||
m.HandleFunc("POST /api/v1/groups/{id}/members", s.authed(store.PermNone, s.handleSetMember))
|
m.HandleFunc("POST /api/v1/groups/{id}/members", s.authed(s.handleSetMember))
|
||||||
m.HandleFunc("DELETE /api/v1/groups/{id}/members/{user}", s.authed(store.PermNone, s.handleRemoveMember))
|
m.HandleFunc("DELETE /api/v1/groups/{id}/members/{user}", s.authed(s.handleRemoveMember))
|
||||||
m.HandleFunc("POST /api/v1/grants", s.authed(store.PermNone, s.handleSetGrant))
|
m.HandleFunc("POST /api/v1/grants", s.authed(s.handleSetGrant))
|
||||||
m.HandleFunc("DELETE /api/v1/grants", s.authed(store.PermNone, s.handleDeleteGrant))
|
m.HandleFunc("DELETE /api/v1/grants", s.authed(s.handleDeleteGrant))
|
||||||
m.HandleFunc("POST /api/v1/invites", s.authed(store.PermNone, s.handleCreateInvite))
|
m.HandleFunc("POST /api/v1/invites", s.authed(s.handleCreateInvite))
|
||||||
m.HandleFunc("GET /api/v1/invites", s.authed(store.PermNone, s.handleListInvites))
|
m.HandleFunc("GET /api/v1/invites", s.authed(s.handleListInvites))
|
||||||
m.HandleFunc("GET /api/v1/users", s.authed(store.PermNone, s.handleListUsers))
|
m.HandleFunc("GET /api/v1/users", s.authed(s.handleListUsers))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- request plumbing ----
|
// ---- request plumbing ----
|
||||||
@@ -185,9 +185,9 @@ func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
|||||||
type handler func(http.ResponseWriter, *http.Request, *store.User)
|
type handler func(http.ResponseWriter, *http.Request, *store.User)
|
||||||
|
|
||||||
// authed resolves the bearer token to a live, enabled user before calling fn.
|
// authed resolves the bearer token to a live, enabled user before calling fn.
|
||||||
// Per-object permissions are checked inside each handler, where the object is
|
// Per-object permission is then checked inside the handler, where the object
|
||||||
// known; min is accepted for documentation of the minimum account level.
|
// being acted on is known.
|
||||||
func (s *Server) authed(_ store.Perm, fn handler) http.HandlerFunc {
|
func (s *Server) authed(fn handler) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
u, err := s.userFromRequest(r)
|
u, err := s.userFromRequest(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
keeporg (0.1.0) unstable; urgency=medium
|
||||||
|
|
||||||
|
* First release.
|
||||||
|
* keeporgd: synchronization server with nested group access control,
|
||||||
|
invite-link and OIDC registration, built-in TOTP second factor,
|
||||||
|
versioned KDBX blobs and server-sent update notifications.
|
||||||
|
* keeporg: desktop client that mirrors assigned databases as ordinary
|
||||||
|
.kdbx files, pushes every save and applies remote changes, with
|
||||||
|
server-wins, client-wins and manual merge strategies.
|
||||||
|
|
||||||
|
-- tiredsysadmin <rcmd@garage.rcmd.space> Thu, 29 Oct 2026 12:00:00 +0000
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
Package: keeporg
|
||||||
|
Version: @VERSION@
|
||||||
|
Section: utils
|
||||||
|
Priority: optional
|
||||||
|
Architecture: @ARCH@
|
||||||
|
Maintainer: tiredsysadmin <rcmd@garage.rcmd.space>
|
||||||
|
Homepage: https://garage.rcmd.space/keeporg
|
||||||
|
Description: KeePass database synchronization client
|
||||||
|
KeepOrg mirrors the KDBX databases a KeepOrg server has assigned to you into
|
||||||
|
one directory as ordinary files, so KeePassXC, KeePass2 or any compatible
|
||||||
|
application opens them directly.
|
||||||
|
.
|
||||||
|
Each save is pushed to the server and changes other people push are applied
|
||||||
|
locally. The client never opens a database and never sees a master password.
|
||||||
|
.
|
||||||
|
A systemd user unit is included: enable it with
|
||||||
|
`systemctl --user enable --now keeporg`.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||||
|
Upstream-Name: keeporg
|
||||||
|
Source: https://garage.rcmd.space/keeporg
|
||||||
|
|
||||||
|
Files: *
|
||||||
|
Copyright: 2026 tiredsysadmin
|
||||||
|
License: AGPL-3.0-or-later
|
||||||
|
This program is free software: you can redistribute it and/or modify it under
|
||||||
|
the terms of the GNU Affero General Public License as published by the Free
|
||||||
|
Software Foundation, either version 3 of the License, or (at your option) any
|
||||||
|
later version.
|
||||||
|
.
|
||||||
|
On Debian systems the full text is in
|
||||||
|
/usr/share/common-licenses/AGPL-3.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
Package: keeporgd
|
||||||
|
Version: @VERSION@
|
||||||
|
Section: net
|
||||||
|
Priority: optional
|
||||||
|
Architecture: @ARCH@
|
||||||
|
Maintainer: tiredsysadmin <rcmd@garage.rcmd.space>
|
||||||
|
Homepage: https://garage.rcmd.space/keeporg
|
||||||
|
Depends: adduser
|
||||||
|
Description: KeePass database synchronization server
|
||||||
|
KeepOrg stores KDBX files as opaque versioned blobs and arbitrates concurrent
|
||||||
|
writes between clients. It hosts many databases, controls access through a
|
||||||
|
tree of user groups, and accepts registrations by invite link or OIDC.
|
||||||
|
.
|
||||||
|
It never opens a database and cannot read one: master passwords stay on the
|
||||||
|
clients.
|
||||||
|
.
|
||||||
|
The service runs as the keeporg user with its data in /var/lib/keeporg and
|
||||||
|
its configuration in /etc/keeporg/server.json. Terminate TLS in front of it
|
||||||
|
with nginx or caddy, or point tls_cert and tls_key at a certificate.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?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>CFBundleName</key>
|
||||||
|
<string>KeepOrg</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>KeepOrg</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>@APPID@</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>@VERSION@</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>@VERSION@</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>KeepOrg</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>11.0</string>
|
||||||
|
<!-- The agent has no window: it syncs in the background. -->
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?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">
|
||||||
|
<!-- Copy to ~/Library/LaunchAgents/@APPID@.plist and run
|
||||||
|
launchctl load ~/Library/LaunchAgents/@APPID@.plist
|
||||||
|
to keep the agent running after login. -->
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>@APPID@</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/Applications/KeepOrg.app/Contents/MacOS/keeporg</string>
|
||||||
|
<string>run</string>
|
||||||
|
</array>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/tmp/keeporg.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Bundle executable: starts the synchronization agent. If this device has not
|
||||||
|
# been signed in yet, open Terminal so the user can do it.
|
||||||
|
set -eu
|
||||||
|
here=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
keeporg="$here/keeporg"
|
||||||
|
config="$HOME/Library/Application Support/keeporg/config.json"
|
||||||
|
|
||||||
|
if [ ! -f "$config" ]; then
|
||||||
|
osascript -e "tell application \"Terminal\" to do script \"'$keeporg' login -server https://keeporg.example.com -user \$USER\"" \
|
||||||
|
-e 'tell application "Terminal" to activate'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exec "$keeporg" run
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=KeepOrg KDBX synchronization agent
|
||||||
|
Documentation=https://garage.rcmd.space/keeporg
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/keeporg run
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
# The agent only ever touches the mirror directory and the config file.
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=KeepOrg KDBX synchronization server
|
||||||
|
Documentation=https://garage.rcmd.space/keeporg
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=keeporg
|
||||||
|
Group=keeporg
|
||||||
|
ExecStart=/usr/bin/keeporgd -config /etc/keeporg/server.json
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StateDirectory=keeporg
|
||||||
|
StateDirectoryMode=0700
|
||||||
|
ConfigurationDirectory=keeporg
|
||||||
|
|
||||||
|
# The server reads one config file and writes one data directory.
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
PrivateDevices=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
ReadWritePaths=/var/lib/keeporg
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
LockPersonality=yes
|
||||||
|
MemoryDenyWriteExecute=yes
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
SystemCallFilter=@system-service
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
AmbientCapabilities=
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
KeepOrg for Windows
|
||||||
|
===================
|
||||||
|
|
||||||
|
keeporg.exe is the synchronization agent and the administration command.
|
||||||
|
|
||||||
|
First run
|
||||||
|
---------
|
||||||
|
keeporg.exe login -server https://keeporg.example.com -user you
|
||||||
|
keeporg.exe run
|
||||||
|
|
||||||
|
`login` stores its configuration in
|
||||||
|
%AppData%\keeporg\config.json and mirrors your databases into
|
||||||
|
%UserProfile%\KeepOrg by default. Pass -dir to choose another directory.
|
||||||
|
|
||||||
|
Open the .kdbx files in that directory with KeePass, KeePassXC or any
|
||||||
|
compatible application. Every save is pushed to the server, and changes made
|
||||||
|
by other people are applied to the files.
|
||||||
|
|
||||||
|
Keeping it running
|
||||||
|
------------------
|
||||||
|
Import keeporg-task.xml into Task Scheduler to start the agent at logon:
|
||||||
|
|
||||||
|
schtasks /create /tn KeepOrg /xml keeporg-task.xml
|
||||||
|
|
||||||
|
Conflicts
|
||||||
|
---------
|
||||||
|
If someone else saved first, your save is kept beside the file as
|
||||||
|
<name>.conflict-<timestamp>.kdbx and the server copy is restored. Open both
|
||||||
|
in KeePass and merge them, or ask a group administrator to switch the
|
||||||
|
database to another merge strategy.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-16"?>
|
||||||
|
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||||||
|
<RegistrationInfo>
|
||||||
|
<Description>KeepOrg KDBX synchronization agent</Description>
|
||||||
|
<URI>\KeepOrg</URI>
|
||||||
|
</RegistrationInfo>
|
||||||
|
<Triggers>
|
||||||
|
<LogonTrigger>
|
||||||
|
<Enabled>true</Enabled>
|
||||||
|
</LogonTrigger>
|
||||||
|
</Triggers>
|
||||||
|
<Principals>
|
||||||
|
<Principal id="Author">
|
||||||
|
<LogonType>InteractiveToken</LogonType>
|
||||||
|
<RunLevel>LeastPrivilege</RunLevel>
|
||||||
|
</Principal>
|
||||||
|
</Principals>
|
||||||
|
<Settings>
|
||||||
|
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
||||||
|
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
||||||
|
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||||||
|
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
||||||
|
<RestartOnFailure>
|
||||||
|
<Interval>PT1M</Interval>
|
||||||
|
<Count>3</Count>
|
||||||
|
</RestartOnFailure>
|
||||||
|
</Settings>
|
||||||
|
<Actions Context="Author">
|
||||||
|
<Exec>
|
||||||
|
<Command>%LOCALAPPDATA%\Programs\KeepOrg\keeporg.exe</Command>
|
||||||
|
<Arguments>run -quiet</Arguments>
|
||||||
|
</Exec>
|
||||||
|
</Actions>
|
||||||
|
</Task>
|
||||||
Executable
+83
@@ -0,0 +1,83 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Build one Debian package.
|
||||||
|
# build-deb.sh <client|server> <version> <arch> <output.deb>
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
kind=$1
|
||||||
|
version=$2
|
||||||
|
arch=$3
|
||||||
|
out=$4
|
||||||
|
|
||||||
|
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
stage=$root/build/deb-$kind-$arch
|
||||||
|
rm -rf "$stage"
|
||||||
|
|
||||||
|
case $kind in
|
||||||
|
client)
|
||||||
|
pkg=keeporg
|
||||||
|
binary=keeporg
|
||||||
|
mkdir -p "$stage/usr/bin" "$stage/usr/lib/systemd/user"
|
||||||
|
install -m 0755 "$root/build/linux-$arch/keeporg" "$stage/usr/bin/keeporg"
|
||||||
|
install -m 0644 "$root/packaging/systemd/keeporg.service" \
|
||||||
|
"$stage/usr/lib/systemd/user/keeporg.service"
|
||||||
|
;;
|
||||||
|
server)
|
||||||
|
pkg=keeporgd
|
||||||
|
binary=keeporgd
|
||||||
|
mkdir -p "$stage/usr/bin" "$stage/usr/lib/systemd/system" "$stage/etc/keeporg"
|
||||||
|
install -m 0755 "$root/build/linux-$arch/keeporgd" "$stage/usr/bin/keeporgd"
|
||||||
|
install -m 0644 "$root/packaging/systemd/keeporgd.service" \
|
||||||
|
"$stage/usr/lib/systemd/system/keeporgd.service"
|
||||||
|
"$root/build/linux-$arch/keeporgd" -write-config "$stage/etc/keeporg/server.json" >/dev/null
|
||||||
|
chmod 0640 "$stage/etc/keeporg/server.json"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "build-deb.sh: unknown package kind $kind" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
mkdir -p "$stage/DEBIAN" "$stage/usr/share/doc/$pkg"
|
||||||
|
sed -e "s/@VERSION@/$version/" -e "s/@ARCH@/$arch/" \
|
||||||
|
"$root/packaging/deb/$kind/control.in" > "$stage/DEBIAN/control"
|
||||||
|
install -m 0644 "$root/packaging/deb/copyright" "$stage/usr/share/doc/$pkg/copyright"
|
||||||
|
gzip -9nc "$root/packaging/deb/changelog" > "$stage/usr/share/doc/$pkg/changelog.Debian.gz"
|
||||||
|
chmod 0644 "$stage/usr/share/doc/$pkg/changelog.Debian.gz"
|
||||||
|
|
||||||
|
if [ "$kind" = server ]; then
|
||||||
|
cat > "$stage/DEBIAN/conffiles" <<'CONF'
|
||||||
|
/etc/keeporg/server.json
|
||||||
|
CONF
|
||||||
|
cat > "$stage/DEBIAN/postinst" <<'POST'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
if [ "$1" = configure ]; then
|
||||||
|
if ! getent passwd keeporg >/dev/null; then
|
||||||
|
adduser --system --group --home /var/lib/keeporg \
|
||||||
|
--gecos "KeepOrg synchronization server" keeporg
|
||||||
|
fi
|
||||||
|
install -d -o keeporg -g keeporg -m 0700 /var/lib/keeporg
|
||||||
|
chgrp keeporg /etc/keeporg/server.json || true
|
||||||
|
chmod 0640 /etc/keeporg/server.json || true
|
||||||
|
if [ -d /run/systemd/system ]; then
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
echo "keeporgd: edit /etc/keeporg/server.json, then" \
|
||||||
|
"systemctl enable --now keeporgd" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
POST
|
||||||
|
cat > "$stage/DEBIAN/prerm" <<'PRERM'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
if [ "$1" = remove ] && [ -d /run/systemd/system ]; then
|
||||||
|
systemctl stop keeporgd || true
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
PRERM
|
||||||
|
chmod 0755 "$stage/DEBIAN/postinst" "$stage/DEBIAN/prerm"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$out")"
|
||||||
|
fakeroot dpkg-deb --root-owner-group --build "$stage" "$out" >/dev/null
|
||||||
|
printf 'built %s (%s %s %s)\n' "$out" "$binary" "$version" "$arch"
|
||||||
Executable
+39
@@ -0,0 +1,39 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Build the macOS disk image: an application bundle beside a symlink to
|
||||||
|
# /Applications, which is what a user expects to drag.
|
||||||
|
# build-dmg.sh <version> <arch> <appid> <output.dmg>
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
version=$1
|
||||||
|
arch=$2
|
||||||
|
appid=$3
|
||||||
|
out=$4
|
||||||
|
|
||||||
|
if [ "$(uname -s)" != Darwin ]; then
|
||||||
|
echo "build-dmg.sh: a DMG can only be built on macOS (hdiutil is required)" >&2
|
||||||
|
echo "the binary itself cross-compiles: make build/darwin-$arch/keeporg" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
stage=$root/build/dmg-$arch
|
||||||
|
app=$stage/KeepOrg.app
|
||||||
|
rm -rf "$stage"
|
||||||
|
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
|
||||||
|
|
||||||
|
install -m 0755 "$root/build/darwin-$arch/keeporg" "$app/Contents/MacOS/keeporg"
|
||||||
|
sed -e "s/@VERSION@/$version/" -e "s/@APPID@/$appid/" \
|
||||||
|
"$root/packaging/macos/Info.plist.in" > "$app/Contents/Info.plist"
|
||||||
|
install -m 0755 "$root/packaging/macos/launcher.sh" "$app/Contents/MacOS/KeepOrg"
|
||||||
|
sed -e "s/@APPID@/$appid/" "$root/packaging/macos/launch-agent.plist.in" \
|
||||||
|
> "$app/Contents/Resources/$appid.plist"
|
||||||
|
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.
|
||||||
|
ln -s /Applications "$stage/Applications"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$out")"
|
||||||
|
rm -f "$out"
|
||||||
|
hdiutil create -volname "KeepOrg $version" -srcfolder "$stage" \
|
||||||
|
-ov -fs HFS+ -format UDZO "$out" >/dev/null
|
||||||
|
printf 'built %s\n' "$out"
|
||||||
Executable
+54
@@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Bump one platform's semantic version in versions.mk.
|
||||||
|
# bump.sh <server|linux|macos|windows|bsd|android|all> <major|minor|patch>
|
||||||
|
# Bumping android also increments its monotonic versionCode.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
platform=${1:-}
|
||||||
|
part=${2:-patch}
|
||||||
|
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
file=$root/versions.mk
|
||||||
|
|
||||||
|
case $platform in
|
||||||
|
server|linux|macos|windows|bsd|android|all) ;;
|
||||||
|
*)
|
||||||
|
echo "usage: make bump PLATFORM=<server|linux|macos|windows|bsd|android|all> PART=<major|minor|patch>" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
case $part in
|
||||||
|
major|minor|patch) ;;
|
||||||
|
*)
|
||||||
|
echo "bump.sh: PART must be major, minor or patch" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
bump_one() {
|
||||||
|
key=VERSION_$(echo "$1" | tr 'a-z' 'A-Z')
|
||||||
|
current=$(sed -n "s/^$key[[:space:]]*:=[[:space:]]*\([0-9.]*\).*/\1/p" "$file")
|
||||||
|
[ -n "$current" ] || { echo "bump.sh: $key not found in versions.mk" >&2; exit 1; }
|
||||||
|
major=$(echo "$current" | cut -d. -f1)
|
||||||
|
minor=$(echo "$current" | cut -d. -f2)
|
||||||
|
patch=$(echo "$current" | cut -d. -f3)
|
||||||
|
case $part in
|
||||||
|
major) major=$((major + 1)); minor=0; patch=0 ;;
|
||||||
|
minor) minor=$((minor + 1)); patch=0 ;;
|
||||||
|
patch) patch=$((patch + 1)) ;;
|
||||||
|
esac
|
||||||
|
next=$major.$minor.$patch
|
||||||
|
sed -i.bak "s/^$key[[:space:]]*:=.*/$(printf '%-16s := %s' "$key" "$next")/" "$file"
|
||||||
|
printf '%s %s -> %s\n' "$1" "$current" "$next"
|
||||||
|
if [ "$1" = android ]; then
|
||||||
|
code=$(sed -n 's/^ANDROID_VERSION_CODE[[:space:]]*:=[[:space:]]*\([0-9]*\).*/\1/p' "$file")
|
||||||
|
sed -i.bak "s/^ANDROID_VERSION_CODE[[:space:]]*:=.*/ANDROID_VERSION_CODE := $((code + 1))/" "$file"
|
||||||
|
printf 'android versionCode %s -> %s\n' "$code" "$((code + 1))"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$platform" = all ]; then
|
||||||
|
for p in server linux macos windows bsd android; do bump_one "$p"; done
|
||||||
|
else
|
||||||
|
bump_one "$platform"
|
||||||
|
fi
|
||||||
|
rm -f "$file.bak"
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Prepend an entry to the Debian changelog.
|
||||||
|
# changelog.sh <version> "what changed"
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
version=${1:-}
|
||||||
|
message=${2:-}
|
||||||
|
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
file=$root/packaging/deb/changelog
|
||||||
|
|
||||||
|
if [ -z "$version" ] || [ -z "$message" ]; then
|
||||||
|
echo 'usage: make changelog MSG="what changed"' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
author=${DEBFULLNAME:-tiredsysadmin}
|
||||||
|
email=${DEBEMAIL:-rcmd@garage.rcmd.space}
|
||||||
|
tmp=$(mktemp)
|
||||||
|
{
|
||||||
|
printf 'keeporg (%s) unstable; urgency=medium\n\n' "$version"
|
||||||
|
printf ' * %s\n\n' "$message"
|
||||||
|
printf ' -- %s <%s> %s\n\n' "$author" "$email" "$(date -R)"
|
||||||
|
cat "$file"
|
||||||
|
} > "$tmp"
|
||||||
|
mv "$tmp" "$file"
|
||||||
|
printf 'added %s to %s\n' "$version" "$file"
|
||||||
Reference in New Issue
Block a user