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.
|
||||
m.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
|
||||
m.HandleFunc("POST /api/v1/auth/logout", s.authed(store.PermNone, s.handleLogout))
|
||||
m.HandleFunc("GET /api/v1/auth/whoami", s.authed(store.PermNone, s.handleWhoami))
|
||||
m.HandleFunc("POST /api/v1/auth/totp/enroll", s.authed(store.PermNone, s.handleTOTPEnroll))
|
||||
m.HandleFunc("POST /api/v1/auth/totp/confirm", s.authed(store.PermNone, s.handleTOTPConfirm))
|
||||
m.HandleFunc("POST /api/v1/auth/logout", s.authed(s.handleLogout))
|
||||
m.HandleFunc("GET /api/v1/auth/whoami", s.authed(s.handleWhoami))
|
||||
m.HandleFunc("POST /api/v1/auth/totp/enroll", s.authed(s.handleTOTPEnroll))
|
||||
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/poll", s.handleDevicePoll)
|
||||
|
||||
@@ -129,25 +129,25 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("GET /oidc/callback", s.handleOIDCCallback)
|
||||
|
||||
// Synchronization.
|
||||
m.HandleFunc("GET /api/v1/databases", s.authed(store.PermNone, s.handleListDatabases))
|
||||
m.HandleFunc("GET /api/v1/databases/{id}/blob", s.authed(store.PermNone, s.handleGetBlob))
|
||||
m.HandleFunc("PUT /api/v1/databases/{id}/blob", s.authed(store.PermNone, s.handlePutBlob))
|
||||
m.HandleFunc("GET /api/v1/events", s.authed(store.PermNone, s.handleEvents))
|
||||
m.HandleFunc("GET /api/v1/databases", s.authed(s.handleListDatabases))
|
||||
m.HandleFunc("GET /api/v1/databases/{id}/blob", s.authed(s.handleGetBlob))
|
||||
m.HandleFunc("PUT /api/v1/databases/{id}/blob", s.authed(s.handlePutBlob))
|
||||
m.HandleFunc("GET /api/v1/events", s.authed(s.handleEvents))
|
||||
|
||||
// Administration.
|
||||
m.HandleFunc("POST /api/v1/databases", s.authed(store.PermNone, s.handleCreateDatabase))
|
||||
m.HandleFunc("PATCH /api/v1/databases/{id}", s.authed(store.PermNone, s.handlePatchDatabase))
|
||||
m.HandleFunc("DELETE /api/v1/databases/{id}", s.authed(store.PermNone, s.handleDeleteDatabase))
|
||||
m.HandleFunc("GET /api/v1/groups", s.authed(store.PermNone, s.handleListGroups))
|
||||
m.HandleFunc("POST /api/v1/groups", s.authed(store.PermNone, s.handleCreateGroup))
|
||||
m.HandleFunc("DELETE /api/v1/groups/{id}", s.authed(store.PermNone, s.handleDeleteGroup))
|
||||
m.HandleFunc("POST /api/v1/groups/{id}/members", s.authed(store.PermNone, s.handleSetMember))
|
||||
m.HandleFunc("DELETE /api/v1/groups/{id}/members/{user}", s.authed(store.PermNone, s.handleRemoveMember))
|
||||
m.HandleFunc("POST /api/v1/grants", s.authed(store.PermNone, s.handleSetGrant))
|
||||
m.HandleFunc("DELETE /api/v1/grants", s.authed(store.PermNone, s.handleDeleteGrant))
|
||||
m.HandleFunc("POST /api/v1/invites", s.authed(store.PermNone, s.handleCreateInvite))
|
||||
m.HandleFunc("GET /api/v1/invites", s.authed(store.PermNone, s.handleListInvites))
|
||||
m.HandleFunc("GET /api/v1/users", s.authed(store.PermNone, s.handleListUsers))
|
||||
m.HandleFunc("POST /api/v1/databases", s.authed(s.handleCreateDatabase))
|
||||
m.HandleFunc("PATCH /api/v1/databases/{id}", s.authed(s.handlePatchDatabase))
|
||||
m.HandleFunc("DELETE /api/v1/databases/{id}", s.authed(s.handleDeleteDatabase))
|
||||
m.HandleFunc("GET /api/v1/groups", s.authed(s.handleListGroups))
|
||||
m.HandleFunc("POST /api/v1/groups", s.authed(s.handleCreateGroup))
|
||||
m.HandleFunc("DELETE /api/v1/groups/{id}", s.authed(s.handleDeleteGroup))
|
||||
m.HandleFunc("POST /api/v1/groups/{id}/members", s.authed(s.handleSetMember))
|
||||
m.HandleFunc("DELETE /api/v1/groups/{id}/members/{user}", s.authed(s.handleRemoveMember))
|
||||
m.HandleFunc("POST /api/v1/grants", s.authed(s.handleSetGrant))
|
||||
m.HandleFunc("DELETE /api/v1/grants", s.authed(s.handleDeleteGrant))
|
||||
m.HandleFunc("POST /api/v1/invites", s.authed(s.handleCreateInvite))
|
||||
m.HandleFunc("GET /api/v1/invites", s.authed(s.handleListInvites))
|
||||
m.HandleFunc("GET /api/v1/users", s.authed(s.handleListUsers))
|
||||
}
|
||||
|
||||
// ---- 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)
|
||||
|
||||
// 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
|
||||
// known; min is accepted for documentation of the minimum account level.
|
||||
func (s *Server) authed(_ store.Perm, fn handler) http.HandlerFunc {
|
||||
// Per-object permission is then checked inside the handler, where the object
|
||||
// being acted on is known.
|
||||
func (s *Server) authed(fn handler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, err := s.userFromRequest(r)
|
||||
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