diff --git a/testing/aaudit/APKBUILD b/testing/aaudit/APKBUILD
index f471ed92fa9413d10f3af185c83bb6ce7c031346..5dcb358aca31c56e8e57e65786495e264af19141 100644
--- a/testing/aaudit/APKBUILD
+++ b/testing/aaudit/APKBUILD
@@ -1,57 +1,71 @@
 # Contributor: Timo Teräs <timo.teras@iki.fi>
 # Maintainer: Timo Teräs <timo.teras@iki.fi>
 pkgname=aaudit
-pkgver=0.3
+pkgver=0.4
 pkgrel=0
 pkgdesc="Alpine Auditor"
 url="http://alpinelinux.org"
 arch="noarch"
 license="GPL"
-depends=""
+depends="lua5.2 lua5.2-posix lua5.2-cjson"
 makedepends=""
 install=""
 subpackages="$pkgname-server"
 replaces=""
-client_bin="aaudit"
-server_bin="aaudit-repo-create aaudit-repo-update aaudit-shell"
-server_lua="aaudit.lua"
-source="$client_bin $server_lua $server_bin aaudit-server.conf"
+source="aaudit-common.lua
+	aaudit-server.lua
+	aaudit
+	aaudit-shell
+	aaudit-update-keys
+	aaudit.json
+	aaudit-server.json
+	"
+_luaver="5.2"
 
 build() {
 	return 0
 }
 
 package() {
-	mkdir -p "$pkgdir"/usr/bin
-	cp $client_bin "$pkgdir"/usr/bin
+	mkdir -p "$pkgdir"/etc/aaudit \
+		"$pkgdir"/usr/bin \
+		"$pkgdir"/usr/share/lua/$_luaver/aaudit
+	cp aaudit.json "$pkgdir"/etc/aaudit
+	cp aaudit-common.lua "$pkgdir"/usr/share/lua/$_luaver/aaudit/common.lua
+	cp aaudit "$pkgdir"/usr/bin
 }
 
 server() {
-	depends="lua5.2 lua5.2-posix git"
+	depends="aaudit git lua5.2 lua5.2-posix lua5.2-cjson"
 
 	mkdir -p "$subpkgdir"/etc/aaudit \
 		"$subpkgdir"/usr/libexec/aaudit \
-		"$subpkgdir"/usr/share/lua/5.2/
-	cp aaudit-server.conf "$subpkgdir"/etc/aaudit
-	cp $server_lua "$subpkgdir"/usr/share/lua/5.2/
-	cp $server_bin "$subpkgdir"/usr/libexec/aaudit
+		"$subpkgdir"/usr/bin \
+		"$subpkgdir"/usr/share/lua/$_luaver/aaudit
+	cp aaudit-server.json "$subpkgdir"/etc/aaudit
+	cp aaudit-server.lua  "$subpkgdir"/usr/share/lua/$_luaver/aaudit/server.lua
+	cp aaudit-update-keys "$subpkgdir"/usr/bin
+	cp aaudit-shell       "$subpkgdir"/usr/libexec/aaudit
 }
 
-md5sums="e8ea430114aab3f07704060605670e0b  aaudit
-c7733c44b464e6e8efe73826d075af17  aaudit.lua
-b11fe0d8285a00a135f8ac9af0206449  aaudit-repo-create
-b900f83afedc4fb1dae2f74c9380fb72  aaudit-repo-update
-0958044c64d1b5c475939687a5620a41  aaudit-shell
-274e2126de7f30170ad6d6acc1bb9ef1  aaudit-server.conf"
-sha256sums="093ded6192adc7ee81ec1e435bac4652355950c30c573cbd0d2f9ab1307f1ade  aaudit
-c4e64cd76a23a6e10f944f88904ec7bc511be90af0659526745086fb732530f7  aaudit.lua
-f01ecd5b99cadbc591d8472f6010d34ad3136085aa35c93d7da56b29a251f6c1  aaudit-repo-create
-2c108a129411373be55a4e4add7ca5c005e05f1ca48be813e9903f7ef84f1e7e  aaudit-repo-update
-8a24abf3ff360f74afbf408d38ad5336a17f59bf0ec9ff553cae4fe0a4bfc376  aaudit-shell
-23e75c1c935d2cd516c489c0c6835178e864595daef45975da54897296aebcf6  aaudit-server.conf"
-sha512sums="b52acc614c4437ed54f348daeb887aca965b62b4e45bbd1f95b731f5e03b360277476b513254e05306387cdea1f196a86b4d9cf5bbc76916707164b45364521d  aaudit
-9d64ba1904639aca31f34aa384cdfce7ddefd17959dfb08904811015343e36959904707ff667879e0fa5587f199ff4dac0213a42d484f983801914dc61ae2899  aaudit.lua
-a8c875eb726e267d6fb56f41cb5c39c45e6f8af8a7a55059bcaff8a0fe8498dac2c90bb21c88e37c34658c574bf2afa8f6ef24e725f602ee1153ba04d9cc84d5  aaudit-repo-create
-e59320cbc6bd7a07687a261399b7df4ef00e349240bee64539a9dfd925b05fb6c679f0f8efb42d1429a7c1d6b918d429a6acb0bc3d4d7f6ef059f9562b748abf  aaudit-repo-update
-492f342115dfe1b622601d11edeb2e5bc87512412645c9f242ce5fe870e6c6a5ee333aa8e3dcb7f9b7f72ebce8b8cb88d6446af39255485c0bd3786ab2c81982  aaudit-shell
-b370c408c242cb4d4c349ca2208e69cdc44c750990c8aacb62e2d8b018cdb87e25c5955fe144352f0fd5c41ff0329ed1118fb3a977aa40e13ddb6b115bf4dd2a  aaudit-server.conf"
+md5sums="b81b0707b297a69dacbc1606324de029  aaudit-common.lua
+4ad8c883f09133a1b9357f7ac156040c  aaudit-server.lua
+b24162b7fa31161eab71485b1077f8ea  aaudit
+07c54e8cb44f195456be7a18b15a0be1  aaudit-shell
+feef077f56f40002ca11846512d347af  aaudit-update-keys
+e14ded329626ca1d6dd48e5bef0bc7e2  aaudit.json
+dc9a54c08ea299ad268301266f3da989  aaudit-server.json"
+sha256sums="ee1998e730356c2de0ff9d5e27d9e0277e3c1f051777146b7c5b820437edfd7f  aaudit-common.lua
+3a9384089b0cb73c151b67eaedd66a244b48c0c2a86f5fe0ccadda6e0cbe8863  aaudit-server.lua
+198b92b5a0eb8e13c3fad87f3afffda1e749243c785e72b54db190413a513595  aaudit
+a99ab6908d780f07b756f5d2416924250b61e88c92ea0aa91af88a05dfc9edff  aaudit-shell
+660dcf86f02a9d0e3ff47cb359e0291a0921d03215e368552a2878d2d691a9cc  aaudit-update-keys
+f61efebc04756c8bfb7cb955b7af5db6a3c5dabdd005f690db812c7e77567cf5  aaudit.json
+878fa7c12ddd28d679703cdf7ab31f69473609d16da9604545f78132cc59d562  aaudit-server.json"
+sha512sums="aaa378fd710d17cb3663954648e97dd5128406cc6f37e9834075046aed1912dcc9e448b6c96502350b8d3496e00b7803cae671a4be2c12c584a84dc0b6e843e9  aaudit-common.lua
+fa74091c9f8f2614f68d828560734b92f16b592b40e9552464ef84dc54f2600f7df91e9ba6159c3a91d54f8d4160078ac022e585633a87edc0cbc6e280a29be9  aaudit-server.lua
+b15515979003382527842cf3fd0c150fb1ff96009518e7cd71c42ab0cf091cee000384d2b8b71c6cc8c4c93ee64cb70bef726d98b3e2b87fff9db7afbc83dc57  aaudit
+974a4e733a61c07719ae75bc1ffc39f01d5adf7bc7f813aa358201bd18711eafeaa42705d9b8d4a869cdb27687091c8998b7eafd1d10a778a429d0efa787bda0  aaudit-shell
+aec728a9a1e4c92baeb94a9d95e1785ea166652a157571fe2e848e71c1246635ecb99512e92435e1314c620b1fa8e4f37400350bed78bd375db4a63828c500f0  aaudit-update-keys
+e769f0f77fe54ba1ab35efc80cc6426e34a2ee1d053ac9e7cc5aa316cfcef0c9658d2f0e2c47f7ae282bb9cc07107065fcc13034b2f9125c182378b7c73b7d99  aaudit.json
+dcc099fe53603a09de225a888242ce329cdb51af3cd0a88dea23cb56d794eb2a442dcdf93b3db18158f8745d5d33de5c82d106f99b1c616fd4936512dfae75d8  aaudit-server.json"
diff --git a/testing/aaudit/aaudit b/testing/aaudit/aaudit
old mode 100644
new mode 100755
index 489fd30164543e5632432c2aabd5964ae01af0d6..a1e835272fe1bffa922ed2304bc4d8c3c923601c
--- a/testing/aaudit/aaudit
+++ b/testing/aaudit/aaudit
@@ -1,9 +1,70 @@
-#!/bin/sh
-CONF="/etc/aaudit/aaudit.conf"
-[ -r "$CONF" ] && . "$CONF"
-AAUDIT_USER=${AAUDIT_USER:-aaudit}
-if [ -z "$AAUDIT_SERVER" ]; then
-	echo "Initialize $CONF with AAUDIT_SERVER=<hostname> first!"
-	exit 0
-fi
-exec ssh $AAUDIT_USER@$AAUDIT_SERVER "$@"
+#!/usr/bin/lua5.2
+
+local posix = require 'posix'
+local json = require 'cjson'
+local aac = require 'aaudit.common'
+
+local function usage()
+	print([[
+Usage: aaudit [create|commit] [OPTIONS...]
+
+Valid options for create:
+	-s SERV	Use server SERV
+	-d DESC	Description for repository (default: hostname)
+	-t ADDR	Specify ADDR as target device (default: local source IP)
+	-m MSG	Specify message for the initial commit
+	-g GRP	Add in group GRP
+
+Valid options for commit:
+	-m MSG	Specify message for the commit
+	-r RT	Related to ticket RT
+	-c RT	Closes ticket RT
+]])
+	os.exit(1)
+end
+
+local conf = aac.readconfig() or {}
+local req = {}
+
+for ret, optval in posix.getopt(arg, 's:d:t:m:g:r:c:') do
+	if ret == 's' then
+		conf.server = optval
+	elseif ret == 'd' then
+		conf.description = optval
+	elseif ret == 't' then
+		conf.target_address = optval
+	elseif ret == 'm' then
+		req.message = optval
+	elseif ret == 'g' then
+		req.groups = req.groups or {}
+		table.insert(req.groups, optval)
+	elseif ret == 'r' then
+		req.ticket = optval
+	elseif ret == 'c' then
+		req.ticket = optval
+		req.ticket_action = "close"
+	else
+		usage()
+	end
+end
+
+if conf.server == nil then
+	print("Error: No server configured.")
+	usage()
+end
+
+req.command = arg[1]
+if arg[1] == "create" then
+	req.description = conf.description or aac.readfile("/etc/hostname"):gsub("\n","")
+	req.ssh_host_key = aac.readfile("/etc/ssh/ssh_host_ecdsa_key.pub")
+		or aac.readfile("/etc/ssh/ssh_host_dsa_key.pub")
+		or aac.readfile("/etc/ssh/ssh_host_rsa_key.pub")
+	aac.writeconfig(conf)
+elseif arg[1] == "commit" then
+else
+	usage()
+end
+
+local F = io.popen(("ssh -T %s@%s "):format(conf.user or "aaudit", conf.server), "w")
+F:write(json.encode(req))
+F:close()
diff --git a/testing/aaudit/aaudit-common.lua b/testing/aaudit/aaudit-common.lua
new file mode 100644
index 0000000000000000000000000000000000000000..d7b1bc4837edd358e684116673f4298588cf8778
--- /dev/null
+++ b/testing/aaudit/aaudit-common.lua
@@ -0,0 +1,31 @@
+local M = {}
+
+local posix = require 'posix'
+local json = require 'cjson'
+
+M.config = "/etc/aaudit/aaudit.json"
+
+function M.readfile(fn)
+	local F = io.open(fn, "r")
+	if F == nil then return nil end
+	local ret = F:read("*all")
+	F:close()
+	return ret
+end
+
+function M.readconfig(fn)
+	fn = fn or M.config
+	local success, res = pcall(json.decode, M.readfile(fn))
+	if not success then io.stderr:write(("Error reading %s: %s\n"):format(fn, res)) end
+	return res
+end
+
+function M.writefile(content, fn)
+	assert(io.open(fn, "w")):write(content):close()
+end
+
+function M.writeconfig(config, fn)
+	M.writefile(json.encode(config), fn or M.config)
+end
+
+return M
diff --git a/testing/aaudit/aaudit-repo-create b/testing/aaudit/aaudit-repo-create
deleted file mode 100755
index 7b3ce912208315c94358b966e5b67c06d1c4a945..0000000000000000000000000000000000000000
--- a/testing/aaudit/aaudit-repo-create
+++ /dev/null
@@ -1,51 +0,0 @@
-#!/usr/bin/lua5.2
-
-local posix = require 'posix'
-local aaudit = require 'aaudit'
-
-local function usage()
-	print("usage: aaudit-repo-create [-a ADDRESS] -d DESCRIPTION [-i COMMIT_IDENTITY] [-m COMMIT_MESSAGE] [-g GROUPS]")
-	os.exit(1)
-end
-
-local C = { initial=true }
-local groups = {}
-local address, description
-
-for ret, optval in posix.getopt(arg, 'a:d:g:i:m:') do
-	if ret == 'a' then
-		address = optval
-	elseif ret == 'd' then
-		description = optval
-	elseif ret == 'g' then
-		groups['"'..optval..'"'] = true
-	elseif ret == 'i' then
-		C.identity = optval
-	elseif ret == 'm' then
-		C.message = optval
-	else
-		usage()
-	end
-end
-
-if not address or not description then usage() end
-
--- For now default to use address as the repository name
-local repo, repohome = address, aaudit.repohome(address)
-
--- Create repository + write config
-os.execute(([[
-mkdir -p %s; cd %s
-git init --quiet --bare
-]]):format(repohome, repohome))
-
-aaudit.write_file(("%s/aaudit.conf"):format(repohome), ([[
-address = "%s";
-description = "%s";
-groups = { %s };
-]]):format(address, description, table.concat(groups, ', ')))
-
-aaudit.write_file(("%s/description"):format(repohome), ("%s (%s)"):format(description, address))
-
--- Initial import of configuration
-aaudit.import_commit(repohome, C)
diff --git a/testing/aaudit/aaudit-repo-update b/testing/aaudit/aaudit-repo-update
deleted file mode 100755
index 3aa4cc5854ae84ea9481dccb37db13956c62a68c..0000000000000000000000000000000000000000
--- a/testing/aaudit/aaudit-repo-update
+++ /dev/null
@@ -1,25 +0,0 @@
-#!/usr/bin/lua5.2
-
-local posix = require 'posix'
-local aaudit = require 'aaudit'
-
-local function usage()
-	print("usage: aaudit-repo-update [-i COMMIT_IDENTITY] [-m COMMIT_MESSAGE]")
-	os.exit(1)
-end
-
-local C = { }
-local address
-for ret, optval in posix.getopt(arg, 'a:i:m:') do
-	if ret == 'a' then
-		address = optval
-	elseif ret == 'i' then
-		C.identity = optval
-	elseif ret == 'm' then
-		C.message = optval
-	else
-		usage()
-	end
-end
-
-aaudit.import_commit(aaudit.repohome(address), C)
diff --git a/testing/aaudit/aaudit-server.conf b/testing/aaudit/aaudit-server.conf
deleted file mode 100644
index 34ad9b2cec2d5485e30f1fb4ff480bc2fb71a616..0000000000000000000000000000000000000000
--- a/testing/aaudit/aaudit-server.conf
+++ /dev/null
@@ -1,29 +0,0 @@
--- smtp_server = "<server>";
-
-identities = {
-};
-
-groups = {
-	all = {
-		notify_email = { "engineers@alpine.local" };
-		track_filemode = true;
-
-		no_track = {
-			"*/.git/*",
-			"*.apk-new",
-			"*~",
-			"etc/unbound/root.hints",
-			"etc/chrony/chrony.drift",
-			"etc/ld.so.cache",
-		};
-		no_notify = {
-			"etc/acf/password",
-		};
-		no_diff = {
-			"etc/shadow*",
-			"*.crt",
-			"*.pem",
-			"*.pfx",
-		};
-	};
-};
diff --git a/testing/aaudit/aaudit-server.json b/testing/aaudit/aaudit-server.json
new file mode 100644
index 0000000000000000000000000000000000000000..b2edcef650a9dc4c346a93ac9d6c1acedd66f225
--- /dev/null
+++ b/testing/aaudit/aaudit-server.json
@@ -0,0 +1,20 @@
+{
+  "smtp_server": "localhost",
+  "identities": {
+    "_default": "Alpine Auditor <auditor@alpine.local>"
+  },
+  "groups": {
+    "all": {
+      "notify_email": [ "Notify Group <config-changes@alpine.local>" ],
+      "track_filemode": true,
+      "no_track": [
+        "*/.git/*", "*.apk-new", "*~",
+        "etc/unbound/root.hints",
+        "etc/chrony/chrony.drift",
+        "etc/ld.so.cache"
+      ],
+      "no_notify": [ "etc/acf/password" ],
+      "no_diff": [ "etc/shadow*", "*.crt","*.pem", "*.pfx" ]
+    }
+  }
+}
diff --git a/testing/aaudit/aaudit.lua b/testing/aaudit/aaudit-server.lua
similarity index 54%
rename from testing/aaudit/aaudit.lua
rename to testing/aaudit/aaudit-server.lua
index 915c177e710cbe6a1c373139e6f7544be6003e6a..56fed28c1412db747fd8b15e358277951f16091d 100644
--- a/testing/aaudit/aaudit.lua
+++ b/testing/aaudit/aaudit-server.lua
@@ -1,40 +1,13 @@
 local M = {}
 
 local posix = require 'posix'
+local json = require 'cjson'
+local aac = require 'aaudit.common'
 
-function M.repohome(repo)
-	return ("%s/%s.git"):format(os.getenv("HOME"), repo)
-end
-
-function M.write_file(filename, content)
-	assert(io.open(filename, "w")):write(content):close()
-end
-
-local function load_config(filename)
-	local F = assert(io.open(filename, "r"))
-	local cfg = "return {" .. F:read("*all").. "}"
-	F:close()
-	return loadstring(cfg, "config:"..filename)()
-end
+local HOME = os.getenv("HOME")
 
 local function merge_bool(a, b) return a or b end
-local function merge_dict(a, b) for k, v in pairs(b) do a[k] = v end return a end
-local function merge_array(a, b) for i=1,#b do a[#a+1] = b[i] end return a end
-
-local function load_repo_configs(repohome)
-	local G = load_config(("%s/aaudit.conf"):format(os.getenv("HOME")))
-	local R = load_config(("%s/aaudit.conf"):format(repohome))
-	-- merge global and per-repository group configs
-	local RG = (G.groups or {}).all
-	for g in pairs(R.groups or {}) do
-		RG.notify_emails = merge_dict(RG.notify_emails, g.notify_emails)
-		RG.track_filemode = merge_bool(RG.track_filemode, g.track_filemode)
-		RG.no_track = merge_array(RG.no_track, g.no_track)
-		RG.no_notify = merge_array(RG.no_notify, g.no_notify)
-		RG.no_diff = merge_array(RG.no_diff, g.no_diff)
-	end
-	return G, R, RG
-end
+local function merge_array(a, b) if b then for i=1,#b do a[#a+1] = b[i] end end return a end
 
 local function match_file(fn, match_list)
 	if not match_list then return false end
@@ -96,7 +69,7 @@ local function read_header_block(block)
 	return header
 end
 
-local function import_tar(TAR, GIT, CI, RG)
+local function import_tar(TAR, GIT, req, G)
 	local branch_ref = "refs/heads/import"
 	local from_ref = "refs/heads/master"
 	local blocksize = 512
@@ -137,7 +110,7 @@ local function import_tar(TAR, GIT, CI, RG)
 		end
 
 		if header.typeflag:match("^[0-46]$") and
-		   not match_file(header.name, RG.no_track) then
+		   not match_file(header.name, G.no_track) then
 			GIT:write('blob\n', 'mark :', nextmark, '\n')
 			if header.typeflag == "2" then
 				GIT:write('data ', tostring(#header.linkname), '\n', header.linkname, '\n')
@@ -151,7 +124,7 @@ local function import_tar(TAR, GIT, CI, RG)
 			if header.mtime > author_time then author_time = header.mtime end
 		end
 	end
-	if RG.track_filemode then
+	if G.track_filemode then
 		GIT:write('blob\n', 'mark :', nextmark, '\n',
 			'data <<END_OF_PERMISSONS\n')
 		for path, v in sortedpairs(all_files) do
@@ -162,20 +135,20 @@ local function import_tar(TAR, GIT, CI, RG)
 
 	GIT:write(([[
 commit %s
-author %s <%s> %d +0000
-committer %s <%s> %d +0000
+author %s %d +0000
+committer %s %d +0000
 data <<END_OF_COMMIT_MESSAGE
 %s
 END_OF_COMMIT_MESSAGE
 
 ]]):format(branch_ref,
-	CI.identity_name, CI.identity_email, author_time,
-	CI.identity_name, CI.identity_email, os.time(),
-	CI.message or "Changes"))
+	req.author.rfc822, author_time,
+	req.author.rfc822, os.time(),
+	req.message or "Changes"))
 
-	if not CI.initial then GIT:write(("from %s^0\n"):format(from_ref)) end
+	if not req.initial then GIT:write(("from %s^0\n"):format(from_ref)) end
 	GIT:write("deleteall\n")
-	if RG.track_filemode then
+	if G.track_filemode then
 		GIT:write(("M %o :%i %s\n"):format(romode, nextmark, '.permissions.txt'))
 	end
 	local path, v
@@ -195,8 +168,8 @@ END_OF_COMMIT_MESSAGE
 	return true
 end
 
-local function generate_diff(repohome, commit, RG)
-	local DIFF = io.popen(("cd %s; git show %s --"):format(repohome, commit), "r")
+local function generate_diff(repodir, commit, G)
+	local DIFF = io.popen(("git --git-dir='%s' show --patch-with-stat '%s' --"):format(repodir, commit), "r")
 	local visible = true
 	local has_changes, has_visible_changes = false, false
 	local text = {}
@@ -204,10 +177,10 @@ local function generate_diff(repohome, commit, RG)
 		local fn = l:match("^diff [^ \t]* a/([^ \t]*)")
 		if fn then
 			has_changes = true
-			visible = not match_file(fn, RG.no_notify)
+			visible = not match_file(fn, G.no_notify)
 			if visible then
 				has_visible_changes = true
-				visible = not match_file(fn, RG.no_diff)
+				visible = not match_file(fn, G.no_diff)
 				if not visible then
 					table.insert(text, "Private file "..fn.." changed")
 				end
@@ -220,48 +193,139 @@ local function generate_diff(repohome, commit, RG)
 	return has_changes, text
 end
 
-local function send_email(addresses, body, CI, G, R, RG)
+local function resolve_email(identities, id)
+	if identities and identities[id] then id = identities[id] end
+	local name, email = id:match("^(.-) *<(.*)>$")
+	if email then return {name=name, email=email, rfc822=("%s <%s>"):format(name, email) } end
+	return {name="", email=name, rfc822=("<%s>"):format(name)}
+end
+
+local function send_email(body, req, S, R, G)
 	if not body then return end
-	if not RG.notify_emails then return end
+	if not G.notify_emails then return end
 
-	local EMAIL = io.popen(("sendmail -t -S %s"):format(G.smtp_server), "w")
+	local to_rfc822 = {}
+	local to_email = {}
+	for _,r in ipairs(G.notify_emails) do
+		local id = resolve_email(S.identities, r)
+		if not to_email[id.email] then
+			to_email[id.email] = true
+			table.insert(to_rfc822, id.rfc822)
+			table.insert(to_email, id.email)
+		end
+	end
+	to_rfc822 = table.concat(to_rfc822, ", ")
+	to_email  = table.concat(to_email, " ")
+
+	local EMAIL = io.popen(('/bin/busybox sendmail -f "%s" -S "%s" %s')
+		:format(req.author.email, S.smtp_server, to_email), "w")
 	EMAIL:write(([[
-From: %s <%s>
+From: %s
 To: %s
 Subject: apkovl changed - %s (%s)
 Date: %s
 
-]]):format(	CI.identity_name, CI.identity_email,
-		table.concat(RG.notify_emails, ", "),
-		R.description, R.address,
-		os.date("%a, %d %b %Y %H:%M:%S")))
+]]):format(req.author.rfc822, to_rfc822, R.description, R.address, os.date("%a, %d %b %Y %H:%M:%S")))
 
 	for _, l in ipairs(body) do EMAIL:write(l,'\n') end
 	EMAIL:close()
+
+	return to_email
+end
+
+local function load_repo_configs(repohome)
+	local S = aac.readconfig(("%s/aaudit-server.json"):format(HOME))
+	local R = aac.readconfig(("%s/aaudit-repo.json"):format(repohome))
+	-- merge global and per-repository group configs
+	local G = (S.groups or {}).all or {}
+	for _, name in pairs(R.groups or {}) do
+		local g = S.groups[name] or {}
+		G.notify_emails = merge_array(G.notify_emails, g.notify_emails)
+		G.track_filemode = merge_bool(G.track_filemode, g.track_filemode)
+		G.no_track = merge_array(G.no_track, g.no_track)
+		G.no_notify = merge_array(G.no_notify, g.no_notify)
+		G.no_diff = merge_array(G.no_diff, g.no_diff)
+	end
+	return S, R, G
 end
 
-function M.import_commit(repohome, CI)
-	local G, R, RG = load_repo_configs(repohome)
+function M.repo_update(req)
+	local repodir = req.repositorydir
+	local S, R, G = load_repo_configs(repodir)
 
-	CI.identity_name, CI.identity_email = table.unpack(G.identities[CI.identity])
-	CI.identity_name  = CI.identity_name  or "Alpine Auditor"
-	CI.identity_email = CI.identity_email or "auditor@alpine.local"
+	req.author = resolve_email(S.identities, req.identity)
 
 	local TAR = io.popen(("ssh root@%s 'lbu package -' | gunzip"):format(R.address), "r")
-	local GIT = io.popen(("cd %s; git fast-import --quiet"):format(repohome), "w")
-	local rc, err = import_tar(TAR, GIT, CI, RG)
+	local GIT = io.popen(("git --git-dir='%s' fast-import --quiet"):format(repodir), "w")
+	local rc, err = import_tar(TAR, GIT, req, G)
 	GIT:close()
 	TAR:close()
 	if not rc then return rc, err end
 
-	local has_changes, email_body = generate_diff(repohome, "import", RG)
+	local has_changes, email_body = generate_diff(repodir, "import", G)
 	if has_changes then
-		if not CI.initial then send_email(CONF, email_body, CI, G, R, RG) end
-		os.execute(("cd %s; git branch --quiet --force master import; git branch --quiet -D import"):format(repohome))
-	else
-		os.execute(("cd %s; git branch --quiet -D import; git gc --quiet --prune=now"):format(repohome))
+		os.execute(("git --git-dir='%s' branch --quiet --force master import;"..
+			    "git --git-dir='%s' branch --quiet -D import")
+			:format(repodir, repodir))
+		local to = nil
+		if not req.initial then
+			to = send_email(email_body, req, S, R, G)
+		end
+		if to then
+			return true, "Committed and notified: "..to
+		else
+			return true, "Commit successful"
+		end
+	end
+
+	os.execute(("git --git-dir='%s' branch --quiet -D import;"..
+		    "git --git-dir='%s' gc --quiet --prune=now")
+		:format(repodir, repodir))
+	return true, "No changes detected"
+end
+
+function M.repo_create(req)
+	-- Create repository + write config
+	local repodir = req.repositorydir
+	os.execute(("mkdir -p '%s'; git init --quiet --bare '%s'")
+		:format(repodir, repodir))
+	aac.writefile(
+		("%s (%s)"):format(req.description, req.target_address),
+		("%s/description"):format(repodir))
+	aac.writeconfig(
+		{ address=req.target_address,
+		  description=req.description,
+		  groups=req.groups },
+		("%s/aaudit-repo.json"):format(repodir))
+
+	-- Inject ssh identity to known_hosts
+	if req.ssh_host_key then
+		local f = io.open(("%s/.ssh/known_hosts"):format(HOME), "a")
+		f:write(("%s %s\n"):format(req.target_address, req.ssh_host_key))
+		f:close()
 	end
+end
 
+function M.handle(req)
+	req.target_address = req.target_address or req.remote_ip
+	req.repositorydir = ("%s/%s.git"):format(HOME, req.target_address)
+	req.initial = false
+	if req.command == "create" then
+		if posix.access(req.repositorydir, "rwx") then
+			return false, "Repository exists already"
+		end
+		M.repo_create(req)
+		req.initial = true
+		req.command = "commit"
+	end
+	if req.command == "commit" then
+		if not posix.access(req.repositorydir, "rwx") then
+			return false, "No such repository"
+		end
+		return M.repo_update(req)
+	else
+		return false,"Invalid request command"
+	end
 end
 
 return M
diff --git a/testing/aaudit/aaudit-shell b/testing/aaudit/aaudit-shell
index 73ebd2e7eb43e2d2202fd5e6044c8458b4fe8560..e54ac3a9778de207363fd2239cffd2cac07b390f 100755
--- a/testing/aaudit/aaudit-shell
+++ b/testing/aaudit/aaudit-shell
@@ -1,14 +1,11 @@
-#!/bin/sh
+#!/usr/bin/lua5.2
 
-local ip="${SSH_CLIENT/ */}"
-local identity="$1"
-[ -z "$ip" -o -z "$identity" ] && exit 1
+local json = require 'cjson'
+local aas = require 'aaudit.server'
 
-set -- $SSH_ORIGINAL_COMMAND
-cmd="$1"
-shift
+local req = json.decode(io.read("*all"))
+req.remote_ip = (os.getenv("SSH_CLIENT") or ""):match("[^ ]+")
+req.identity = arg[1]
 
-case "$cmd" in
-create) /usr/libexec/aaudit/aaudit-repo-create -a "$ip" "$@" -i "$identity" ;;
-commit) /usr/libexec/aaudit/aaudit-repo-update -a "$ip" "$@" -i "$identity" ;;
-esac
+local ok, msg = aas.handle(req)
+print(json.encode{ok=ok,msg=msg})
diff --git a/testing/aaudit/aaudit-update-keys b/testing/aaudit/aaudit-update-keys
new file mode 100755
index 0000000000000000000000000000000000000000..3521808cbac708cbd5f1fec6c51f19f35ad76ae5
--- /dev/null
+++ b/testing/aaudit/aaudit-update-keys
@@ -0,0 +1,18 @@
+#!/usr/bin/lua5.2
+
+local posix = require 'posix'
+local aac = require 'aaudit.common'
+
+local home = os.getenv("HOME")
+local allkeys = {}
+for _, keyfile in ipairs(posix.glob(("%s/keydir/*.pub"):format(home))) do
+	local identity = keyfile:match("keydir/(.*).pub$")
+	for sshkey in io.lines(keyfile) do
+		table.insert(allkeys,
+			('command="/usr/libexec/aaudit/aaudit-shell %s"'..
+			 ',no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %s\n')
+			:format(identity, sshkey))
+	end
+end
+
+aac.writefile(table.concat(allkeys), ("%s/.ssh/authorized_keys"):format(home))
diff --git a/testing/aaudit/aaudit.json b/testing/aaudit/aaudit.json
new file mode 100644
index 0000000000000000000000000000000000000000..958d60fbc620f0a267bbbf8ecc7b7fe3347b29a9
--- /dev/null
+++ b/testing/aaudit/aaudit.json
@@ -0,0 +1,5 @@
+{
+  "user": "aaudit",
+  "server": "aaudit.alpine.local",
+  "rtqueue": "alpine.org"
+}