feat(audiobookshelf): bootstrap the account, library and scan from the deployer #119
No files matched your search
Executable
+188
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env bash
|
||||
# audiobookshelf-init.sh — make an Audiobookshelf server usable, from nothing.
|
||||
#
|
||||
# Why this is a script and not a browser session (live 2026-11-02, Dankserver):
|
||||
# a freshly deployed Audiobookshelf answers /healthcheck with 200 while it has no
|
||||
# user, no library and no scan — "healthy" says nothing about usefulness, and a
|
||||
# service that only works after someone remembers to click through a first-run
|
||||
# wizard is not deployed, it is installed. Everything below is the API the React
|
||||
# client itself calls, so the same procedure runs unattended and lands in git.
|
||||
#
|
||||
# The API facts this script depends on, read off the pinned image rather than
|
||||
# guessed (dist-server/server/Server.js inside the digest this repo pins):
|
||||
# * the creation route is POST /init with {"newRoot":{"username","password"}}.
|
||||
# There is NO /setup route in 2.37.x — POST /setup answers "Unauthorized",
|
||||
# which reads exactly like an auth fault and is instead a missing route.
|
||||
# * /status (no /api prefix) reports {"isInit": <has a root user>}; every route
|
||||
# under /api needs the bearer token that POST /login returns.
|
||||
# * the library count is NOT a field on the library DTO; the door is
|
||||
# GET /api/libraries/{id}/items -> {"total": N}.
|
||||
#
|
||||
# The credential is generated once into a mode-600 host file and never enters
|
||||
# the repo, the URL, or a process argument list. Same pattern as calibre-server.
|
||||
#
|
||||
# usage: audiobookshelf-init.sh # create what is missing, then scan
|
||||
# ABS_SCAN=0 audiobookshelf-init.sh # bootstrap without a scan
|
||||
#
|
||||
# environment (defaults are the deployed shape):
|
||||
# ABS_URL server base URL (http://127.0.0.1:8000)
|
||||
# ABS_SECRET credential file (/home/dank/.config/secrets/audiobookshelf)
|
||||
# ABS_LIBRARY library name to ensure (Audiobooks)
|
||||
# ABS_FOLDER folder to bind inside the server (/audiobooks)
|
||||
# ABS_SCAN 1 triggers a scan (default), 0 skips it
|
||||
#
|
||||
# exit 0 every step is satisfied (created or already present)
|
||||
# exit 1 a step was refused or the server misbehaved (named on stderr)
|
||||
# exit 2 usage / unreadable credential file
|
||||
set -euo pipefail
|
||||
|
||||
ABS_URL=${ABS_URL:-http://127.0.0.1:8000}
|
||||
ABS_SECRET=${ABS_SECRET:-/home/dank/.config/secrets/audiobookshelf}
|
||||
ABS_LIBRARY=${ABS_LIBRARY:-Audiobooks}
|
||||
ABS_FOLDER=${ABS_FOLDER:-/audiobooks}
|
||||
ABS_SCAN=${ABS_SCAN:-1}
|
||||
|
||||
die() { printf 'refused: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
case "$ABS_SCAN" in
|
||||
0 | 1) ;;
|
||||
*) printf 'refused: ABS_SCAN wants 0 or 1, got %s\n' "$ABS_SCAN" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# ---- credential -------------------------------------------------------------
|
||||
umask 077
|
||||
if [ ! -f "$ABS_SECRET" ]; then
|
||||
mkdir -p "$(dirname "$ABS_SECRET")"
|
||||
user=${ABS_ADMIN_USER:-frank}
|
||||
# `tr … | head -c 24` is the obvious line and it is a trap under
|
||||
# `set -o pipefail`: head exits at 24 bytes, tr takes SIGPIPE, the pipeline
|
||||
# status is 141 and set -e kills the script before the credential is ever
|
||||
# written (measured 2026-11-02). od reads a FIXED byte count and exits on its
|
||||
# own, so no consumer closes a pipe early; bash slices the length.
|
||||
pass=$(od -An -tx1 -N 32 /dev/urandom | tr -d ' \n')
|
||||
pass=${pass:0:24}
|
||||
[ "${#pass}" -eq 24 ] || die "could not generate a password (no /dev/urandom)"
|
||||
printf 'ABS_ADMIN_USER=%s\nABS_ADMIN_PASSWORD=%s\n' "$user" "$pass" > "$ABS_SECRET"
|
||||
printf 'credential: created %s\n' "$ABS_SECRET"
|
||||
fi
|
||||
[ -r "$ABS_SECRET" ] || die "$ABS_SECRET exists but is not readable by $(id -un)"
|
||||
chmod 600 "$ABS_SECRET"
|
||||
|
||||
# ---- everything else, over HTTP ---------------------------------------------
|
||||
# python3 rather than curl so the password and the bearer token never appear in
|
||||
# any argv (they would be world-readable in /proc/<pid>/cmdline otherwise).
|
||||
ABS_URL="$ABS_URL" ABS_SECRET="$ABS_SECRET" ABS_LIBRARY="$ABS_LIBRARY" \
|
||||
ABS_FOLDER="$ABS_FOLDER" ABS_SCAN="$ABS_SCAN" python3 - <<'PY'
|
||||
import json, os, sys, urllib.error, urllib.request
|
||||
|
||||
BASE = os.environ["ABS_URL"].rstrip("/")
|
||||
LIB = os.environ["ABS_LIBRARY"]
|
||||
FOLDER = os.environ["ABS_FOLDER"]
|
||||
SCAN = os.environ["ABS_SCAN"] == "1"
|
||||
cred = {}
|
||||
for line in open(os.environ["ABS_SECRET"]):
|
||||
if "=" in line:
|
||||
k, _, v = line.strip().partition("=")
|
||||
cred[k] = v
|
||||
user = cred.get("ABS_ADMIN_USER") or "frank"
|
||||
password = cred.get("ABS_ADMIN_PASSWORD") or ""
|
||||
if not password:
|
||||
sys.exit("refused: %s has no ABS_ADMIN_PASSWORD" % os.environ["ABS_SECRET"])
|
||||
|
||||
|
||||
def call(method, path, body=None, token=None, want_json=True):
|
||||
data = None
|
||||
headers = {"X-Requested-With": "audiobookshelf-init"}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode()
|
||||
headers["Content-Type"] = "application/json"
|
||||
if token:
|
||||
headers["Authorization"] = "Bearer " + token
|
||||
req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
raw = r.read()
|
||||
if not raw or not want_json:
|
||||
return r.status, raw
|
||||
try:
|
||||
return r.status, json.loads(raw)
|
||||
except ValueError:
|
||||
# Express answers sendStatus() with the STATUS TEXT as the body:
|
||||
# POST /init and the scan route both reply "OK" (measured on the
|
||||
# pinned 2.37.2 image). A successful call must never be judged by
|
||||
# whether its body happens to parse as JSON.
|
||||
return r.status, raw.decode("utf-8", "replace")[:200]
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except Exception:
|
||||
payload = raw.decode("utf-8", "replace")[:200]
|
||||
return e.code, payload
|
||||
except urllib.error.URLError as e:
|
||||
# Not an HTTP answer at all: nothing listening, DNS dead, connection
|
||||
# refused. HTTPError is a URLError subclass, so this arm must come after.
|
||||
return 0, "unreachable (%s)" % e.reason
|
||||
|
||||
|
||||
status, st = call("GET", "/status")
|
||||
if status != 200 or not isinstance(st, dict):
|
||||
sys.exit("refused: %s/status answered %s (%s) — is the service up?" % (BASE, status, st))
|
||||
if st.get("isInit"):
|
||||
print("account: already registered")
|
||||
else:
|
||||
code, body = call("POST", "/init", {"newRoot": {"username": user, "password": password}})
|
||||
if code != 200:
|
||||
sys.exit("refused: POST /init answered %s (%s)" % (code, body))
|
||||
print("account: created (%s)" % user)
|
||||
|
||||
code, login = call("POST", "/login", {"username": user, "password": password})
|
||||
if code != 200 or not isinstance(login, dict) or not login.get("user", {}).get("token"):
|
||||
sys.exit("refused: login answered %s (%s) — the credential file does not match the server"
|
||||
% (code, login))
|
||||
token = login["user"]["token"]
|
||||
print("token: obtained (%s)" % login["user"].get("type", "user"))
|
||||
|
||||
code, libs = call("GET", "/api/libraries", token=token)
|
||||
if code != 200 or not isinstance(libs, dict) or "libraries" not in libs:
|
||||
sys.exit("refused: GET /api/libraries answered %s (%s)" % (code, libs))
|
||||
lib = next((l for l in libs["libraries"] if l.get("name") == LIB), None)
|
||||
if lib:
|
||||
print("library: exists %s (%s)" % (LIB, lib["id"]))
|
||||
else:
|
||||
body = {
|
||||
"name": LIB,
|
||||
"provider": "audiobook",
|
||||
"icon": "book",
|
||||
"folders": [{"fullPath": FOLDER}],
|
||||
"settings": {
|
||||
"coverEncoding": "jpg",
|
||||
"skipMatchingMediaWithAsin": False,
|
||||
"skipMatchingMediaWithIsbn": False,
|
||||
"autoDetectCrossoverAudiobooks": False,
|
||||
"ignoreAudiobookMetadata": False,
|
||||
"useAudiobookFileMetadata": False,
|
||||
},
|
||||
"providerMetadata": {},
|
||||
}
|
||||
code, made = call("POST", "/api/libraries", body, token=token)
|
||||
if code != 200:
|
||||
sys.exit("refused: POST /api/libraries answered %s (%s)" % (code, made))
|
||||
lib = made
|
||||
print("library: created %s (%s) folder %s" % (LIB, lib["id"], FOLDER))
|
||||
|
||||
if SCAN:
|
||||
code, body = call("POST", "/api/libraries/%s/scan" % lib["id"], {}, token=token,
|
||||
want_json=False)
|
||||
if code != 200:
|
||||
sys.exit("refused: scan answered %s (%s)" % (code, body))
|
||||
print("scan: accepted")
|
||||
else:
|
||||
print("scan: skipped (ABS_SCAN=0)")
|
||||
|
||||
code, items = call("GET", "/api/libraries/%s/items?limit=1" % lib["id"], token=token)
|
||||
if code == 200 and isinstance(items, dict):
|
||||
print("items: %s (a fresh scan raises this from 0; watch it, not /healthcheck)"
|
||||
% items.get("total"))
|
||||
PY
|
||||
exit 0
|
||||
@@ -1318,6 +1318,12 @@ deploy_audiobookshelf() {
|
||||
# /healthcheck answers once the HTTP server is bound; the library scan that
|
||||
# follows runs in the background and must not gate the deploy.
|
||||
wait_for_health audiobookshelf http://127.0.0.1:8000/healthcheck 90
|
||||
# A server with no root user and no library is "healthy" and useless, so the
|
||||
# bootstrapper runs after the health gate and its failure fails the deploy:
|
||||
# it creates the account (credential in the mode-600 host secret file), the
|
||||
# Audiobooks library over the /audiobooks bind, and fires the scan. Every
|
||||
# step is idempotent, so a re-deploy costs one incremental scan.
|
||||
scripts/audiobookshelf-init.sh
|
||||
}
|
||||
|
||||
# The content server's single admin account and the desktop's basic-auth
|
||||
|
||||
@@ -104,6 +104,12 @@ for path in "${CHANGED[@]}"; do
|
||||
dankserver/jellyfin/quadlet/*|dankserver/plex/quadlet/*)
|
||||
add_test tests/media_overlay.bats
|
||||
;;
|
||||
# The Audiobookshelf bootstrapper (account + library + scan over the API)
|
||||
# and the deployer hook that runs it after the health gate.
|
||||
scripts/audiobookshelf-init.sh|tests/audiobookshelf_init.bats)
|
||||
add_test tests/audiobookshelf_init.bats
|
||||
add_test tests/deployment_flow.bats
|
||||
;;
|
||||
shared/homepage/*)
|
||||
add_test tests/homepage_caddy.bats
|
||||
add_test tests/homepage_config.bats
|
||||
|
||||
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