feat(audiobookshelf): bootstrap the account, library and scan from the deployer
A freshly deployed Audiobookshelf answers /healthcheck and is otherwise dead: no
account, no library, no scan. The UI registration step is gone by design — the
doors are POST /init (200 with plain text "OK", not JSON) and /status (isInit),
and there is no /setup route. That is an API-only bootstrap, so it must be code.
scripts/audiobookshelf-init.sh is idempotent: /status -> /init when unregistered,
POST /login for the bearer, POST /api/libraries for the Audiobooks library on
/audiobooks, then POST /api/libraries/{id}/scan. The deployer runs it only after
the health gate has answered.
Credentials live in ~/.config/secrets/audiobookshelf (mode 600): the bearer rides
an Authorization header and the password only a POST body — never a URL or argv,
where any ps on the host would print them. The seam drives an inline stub server
that logs every request line, and asserts: fresh bootstrap creates account,
library and scan; a second run creates nothing new and still fires the
incremental scan; no secret appears in any logged request line; a server that
refuses is reported as "refused: <url>" with exit 1 rather than success; ABS_SCAN
outside 0|1 fails before a single request; the deployer orders init after health.
Measured on Dankserver: run against the already-bootstrapped host it is a no-op
that re-fires the scan; the library then scans (1,500+ books, every item with
audio tracks, none invalid).
This commit is contained in:
1 parent
7a8c6bd2e3
commit
317f36b456
4 files changed
+400
No files matched your search
Executable
+200
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env bats
|
||||
# Seam: scripts/audiobookshelf-init.sh — the Audiobookshelf bootstrapper.
|
||||
#
|
||||
# A freshly deployed Audiobookshelf answers /healthcheck 200 while it has no
|
||||
# user, no library and no scan, so "healthy" proves nothing about usefulness.
|
||||
# This seam pins what the bootstrapper must make true, against a stub that
|
||||
# speaks the same API as the pinned image (POST /init + POST /login at the
|
||||
# root, bearer-authenticated /api/*, library count only on /items):
|
||||
# 1. an unregistered server gains the account, the library and a scan, and the
|
||||
# credential lands mode 600 with both keys;
|
||||
# 2. a second run creates nothing new (exactly one /init, one library) and
|
||||
# still fires the incremental scan;
|
||||
# 3. neither the bearer token nor the password ever rides a URL — a secret in
|
||||
# a URL or an argv is world-readable in /proc/<pid>/cmdline, which is how
|
||||
# an HF token leaked into a transcript on 2026-09-16;
|
||||
# 4. a server that cannot be reached is refused by URL with exit 1, never
|
||||
# reported as success (a green deploy over a dead API is a lie);
|
||||
# 5. ABS_SCAN outside 0/1 is a usage error raised before anything is touched;
|
||||
# 6. the deployer runs the bootstrapper only after the health gate answered.
|
||||
|
||||
load helpers
|
||||
|
||||
setup() {
|
||||
INIT="$REPO_ROOT/scripts/audiobookshelf-init.sh"
|
||||
TMP="$(mktemp -d)"
|
||||
PORT=18937
|
||||
cat > "$TMP/stub.py" <<'PY'
|
||||
import json, sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
port, logf = int(sys.argv[1]), sys.argv[2]
|
||||
state = {"init": False, "user": None, "pw": None, "libs": []}
|
||||
|
||||
|
||||
def log(line):
|
||||
with open(logf, "a") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def send(self, code, body, as_json=False):
|
||||
data = json.dumps(body).encode() if as_json else str(body).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json" if as_json else "text/plain")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def body(self):
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
return json.loads(self.rfile.read(n) or b"{}")
|
||||
|
||||
def authed(self):
|
||||
return self.headers.get("Authorization") == "Bearer stub-token"
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split("?")[0]
|
||||
log("GET %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no"))
|
||||
if path == "/status":
|
||||
return self.send(200, {"app": "audiobookshelf", "isInit": state["init"]}, True)
|
||||
if not self.authed():
|
||||
return self.send(401, "Unauthorized")
|
||||
if path == "/api/libraries":
|
||||
return self.send(200, {"libraries": state["libs"]}, True)
|
||||
if path.endswith("/items"):
|
||||
return self.send(200, {"total": 42, "results": []}, True)
|
||||
return self.send(404, "nope")
|
||||
|
||||
def do_POST(self):
|
||||
path = self.path.split("?")[0]
|
||||
payload = self.body()
|
||||
log("POST %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no"))
|
||||
if path == "/init":
|
||||
if state["init"]:
|
||||
return self.send(500, "already initialized")
|
||||
root = payload.get("newRoot", {})
|
||||
state["init"] = True
|
||||
state["user"] = root.get("username")
|
||||
state["pw"] = root.get("password")
|
||||
log("INIT %s" % root.get("username"))
|
||||
return self.send(200, "OK")
|
||||
if path == "/login":
|
||||
if not state["init"]:
|
||||
return self.send(401, "Unauthorized")
|
||||
if (payload.get("username"), payload.get("password")) != (state["user"], state["pw"]):
|
||||
return self.send(401, "Unauthorized")
|
||||
return self.send(200, {"user": {"username": state["user"], "type": "root",
|
||||
"id": "u1", "token": "stub-token"}}, True)
|
||||
if not self.authed():
|
||||
return self.send(401, "Unauthorized")
|
||||
if path == "/api/libraries":
|
||||
lib = {"id": "lib-1", "name": payload.get("name"), "folders": payload.get("folders", [])}
|
||||
state["libs"].append(lib)
|
||||
log("LIBRARY %s %s" % (lib["name"], lib["folders"]))
|
||||
return self.send(200, lib, True)
|
||||
if path.endswith("/scan"):
|
||||
log("SCAN")
|
||||
return self.send(200, "OK")
|
||||
return self.send(404, "nope")
|
||||
|
||||
|
||||
ThreadingHTTPServer(("127.0.0.1", port), H).serve_forever()
|
||||
PY
|
||||
python3 "$TMP/stub.py" "$PORT" "$TMP/requests.log" > "$TMP/stub.out" 2>&1 &
|
||||
STUB_PID=$!
|
||||
for _ in $(seq 1 50); do
|
||||
curl -s -o /dev/null "http://127.0.0.1:$PORT/status" && break
|
||||
sleep 0.1
|
||||
done
|
||||
}
|
||||
|
||||
teardown() {
|
||||
[ -n "${STUB_PID:-}" ] && kill "$STUB_PID" 2>/dev/null || true
|
||||
rm -rf "$TMP"
|
||||
}
|
||||
|
||||
boot() {
|
||||
env ABS_URL="http://127.0.0.1:$PORT" ABS_SECRET="$TMP/cred" \
|
||||
ABS_ADMIN_USER=frank ABS_LIBRARY=Audiobooks ABS_FOLDER=/audiobooks \
|
||||
ABS_SCAN="${SCAN:-1}" bash "$INIT"
|
||||
}
|
||||
|
||||
@test "an unregistered server gains the account, the library and the scan" {
|
||||
run boot
|
||||
[ "$status" -eq 0 ]
|
||||
printf '%s\n' "${lines[@]}" | grep -qx "credential: created $TMP/cred"
|
||||
printf '%s\n' "${lines[@]}" | grep -q "^account: created (frank)"
|
||||
printf '%s\n' "${lines[@]}" | grep -q "^token: obtained (root)"
|
||||
printf '%s\n' "${lines[@]}" | grep -q "^library: created Audiobooks (lib-1)"
|
||||
printf '%s\n' "${lines[@]}" | grep -q "^scan: accepted"
|
||||
printf '%s\n' "${lines[@]}" | grep -q "^items: 42"
|
||||
# The state roots of this service hold an account credential, so the file
|
||||
# that carries it must never be group- or world-readable.
|
||||
[ "$(stat -c %a "$TMP/cred")" = "600" ]
|
||||
grep -qx 'ABS_ADMIN_USER=frank' "$TMP/cred"
|
||||
grep -Eq '^ABS_ADMIN_PASSWORD=[0-9a-f]{24}$' "$TMP/cred"
|
||||
# The folder the library indexes is the bind inside the container, not the
|
||||
# host path — a library pointed at /mnt/entropy would answer 0 items and
|
||||
# look like an empty audiobook collection.
|
||||
grep -q "LIBRARY Audiobooks \[{'fullPath': '/audiobooks'}\]" "$TMP/requests.log"
|
||||
}
|
||||
|
||||
@test "a second run creates nothing new and still fires the incremental scan" {
|
||||
boot > /dev/null
|
||||
[ -e "$TMP/requests.log" ]
|
||||
run boot
|
||||
[ "$status" -eq 0 ]
|
||||
printf '%s\n' "${lines[@]}" | grep -q "^account: already registered"
|
||||
printf '%s\n' "${lines[@]}" | grep -q "^library: exists Audiobooks (lib-1)"
|
||||
printf '%s\n' "${lines[@]}" | grep -q "^scan: accepted"
|
||||
# No second credential, no second account, no duplicate library: the deployer
|
||||
# may run this on every re-deploy of the service.
|
||||
! printf '%s\n' "${lines[@]}" | grep -q "credential: created"
|
||||
[ "$(grep -c '^INIT ' "$TMP/requests.log")" -eq 1 ]
|
||||
[ "$(grep -c '^LIBRARY ' "$TMP/requests.log")" -eq 1 ]
|
||||
[ "$(grep -c '^SCAN$' "$TMP/requests.log")" -eq 2 ]
|
||||
}
|
||||
|
||||
@test "the bearer token and the password never ride a URL" {
|
||||
boot
|
||||
# The stub logs the request line (method + path). A secret showing up there
|
||||
# means it travelled as a query parameter — visible in access logs, shell
|
||||
# history and /proc/<pid>/cmdline.
|
||||
! awk '{print $2}' "$TMP/requests.log" | grep -q 'stub-token'
|
||||
pw=$(awk -F= '/^ABS_ADMIN_PASSWORD=/{print $2}' "$TMP/cred")
|
||||
[ -n "$pw" ]
|
||||
! awk '{print $2}' "$TMP/requests.log" | grep -q -- "$pw"
|
||||
# and the authenticated routes only answered once the header was present
|
||||
grep -q '^GET /api/libraries auth=yes$' "$TMP/requests.log"
|
||||
}
|
||||
|
||||
@test "a server that does not answer is refused by URL, not reported as success" {
|
||||
run env ABS_URL="http://127.0.0.1:1" ABS_SECRET="$TMP/cred" bash "$INIT"
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *refused:* ]]
|
||||
[[ "$output" == *"127.0.0.1:1"* ]]
|
||||
}
|
||||
|
||||
@test "ABS_SCAN outside 0/1 is a usage error raised before anything is touched" {
|
||||
run env ABS_URL="http://127.0.0.1:$PORT" ABS_SECRET="$TMP/cred" ABS_SCAN=2 \
|
||||
bash "$INIT"
|
||||
[ "$status" -eq 2 ]
|
||||
[[ "$output" == *ABS_SCAN* ]]
|
||||
[ ! -e "$TMP/cred" ]
|
||||
}
|
||||
|
||||
@test "the deployer bootstraps only after the health gate has answered" {
|
||||
body=$(awk '/^deploy_audiobookshelf\(\)/,/^}/' "$REPO_ROOT/scripts/deploy-remote.sh")
|
||||
[ -n "$body" ]
|
||||
printf '%s\n' "$body" | grep -q \
|
||||
'wait_for_health audiobookshelf http://127\.0\.0\.1:8000/healthcheck'
|
||||
printf '%s\n' "$body" | grep -q 'scripts/audiobookshelf-init\.sh'
|
||||
printf '%s\n' "$body" | awk '
|
||||
/wait_for_health audiobookshelf/ { health = NR }
|
||||
/audiobookshelf-init\.sh/ { init = NR }
|
||||
END { exit !(health && init && init > health) }'
|
||||
}
|
||||
Reference in new issue
Block a user