#!/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": }; 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. # # Single sign-on (authentik) is armed by the same run, and only when the credential # file carries ABS_OIDC_CLIENT_SECRET, so a local-only deployment is unchanged by a # re-run. These OIDC facts came from the same pinned image, and each one is a trap: # * isOpenIDAuthSettingsValid demands issuer + authorization + token + userinfo + # jwks + clientID + clientSecret + signingAlgorithm at once. Miss one and the # server SILENTLY strips 'openid' from authActiveAuthMethods and falls back to # ['local'] while answering 200 everywhere — so the only honest check is what # /status says AFTER the write, which is what this script does. # * the algorithm is HS256, not the image's RS256 default: no provider on the # fleet's authentik carries a signing key, so the client secret IS the signature # key (scripts/authentik-audiobookshelf-sso.sh). Left at RS256, every id_token # fails verification and the failure looks like a broken IdP. # * the auth block lives on PATCH /api/auth-settings. PATCH /api/settings has a # 20-key allowlist (PATCHABLE_SETTINGS_KEYS) with NO auth key in it, so writing # the OIDC config there answers 200 and changes nothing — measured on this host # 2026-11-02. The keys /api/auth-settings accepts are exactly its own # authenticationSettings getters; anything else is skipped with a debug line. # * authOpenIDSubfolderForRedirectURLs must be sent explicitly. The 2.17.4 migration only # fills it on a DB that ALREADY had OIDC on, so a database created before SSO leaves it # UNDEFINED and the server builds redirect_uri # "http://host/undefined/auth/openid/callback" — authentik answers the authorize request # with "Redirect URI Error" (measured 2026-11-02). This script derives the value from # ABS_URL's path, so it cannot drift from the unit's ROUTER_BASE_PATH. # * 'local' stays in authActiveAuthMethods on purpose: if authentik is ever down, # the root account must still be able to sign into its own server. # # usage: audiobookshelf-init.sh # create what is missing, then scan # ABS_SCAN=0 audiobookshelf-init.sh # bootstrap without a scan # ABS_OIDC=0 audiobookshelf-init.sh # never touch the auth methods # # environment (defaults are the deployed shape): # ABS_URL server base URL INCLUDING the mounted base path # (http://127.0.0.1:8000/audiobookshelf) # 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 # ABS_OIDC auto (default) arms SSO when the credential file has a client # secret, 1 requires one, 0 leaves the auth methods alone # ABS_OIDC_ISSUER authentik issuer URL for this client # ABS_OIDC_CLIENT_ID provider client id (audiobookshelf) # ABS_OIDC_ALG id_token signing algorithm the IdP actually uses (HS256) # ABS_OIDC_SUBFOLDER override for the redirect-URI subfolder (default: ABS_URL's path) # # 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/audiobookshelf} ABS_SECRET=${ABS_SECRET:-/home/dank/.config/secrets/audiobookshelf} ABS_LIBRARY=${ABS_LIBRARY:-Audiobooks} ABS_FOLDER=${ABS_FOLDER:-/audiobooks} ABS_SCAN=${ABS_SCAN:-1} ABS_OIDC=${ABS_OIDC:-auto} ABS_OIDC_ISSUER=${ABS_OIDC_ISSUER:-https://auth.franksdank.cloud/application/o/audiobookshelf/} ABS_OIDC_CLIENT_ID=${ABS_OIDC_CLIENT_ID:-audiobookshelf} ABS_OIDC_ALG=${ABS_OIDC_ALG:-HS256} 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 case "$ABS_OIDC" in auto | 0 | 1) ;; *) printf 'refused: ABS_OIDC wants auto, 0 or 1, got %s\n' "$ABS_OIDC" >&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//cmdline otherwise). ABS_URL="$ABS_URL" ABS_SECRET="$ABS_SECRET" ABS_LIBRARY="$ABS_LIBRARY" \ ABS_FOLDER="$ABS_FOLDER" ABS_SCAN="$ABS_SCAN" ABS_OIDC="$ABS_OIDC" \ ABS_OIDC_ISSUER="$ABS_OIDC_ISSUER" ABS_OIDC_CLIENT_ID="$ABS_OIDC_CLIENT_ID" \ ABS_OIDC_ALG="$ABS_OIDC_ALG" python3 - <<'PY' import json, os, sys, urllib.error, urllib.parse, 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")) # ---- single sign-on (authentik) -------------------------------------------------- # The provider this points at is provisioned by scripts/authentik-audiobookshelf-sso.sh; # the two share exactly one value, the client secret in the credential file. mode = os.environ["ABS_OIDC"] oidc_secret = cred.get("ABS_OIDC_CLIENT_SECRET") or "" if mode == "0": print("oidc: skipped (ABS_OIDC=0)") elif not oidc_secret: if mode == "1": sys.exit("refused: ABS_OIDC=1 but %s has no ABS_OIDC_CLIENT_SECRET — run " "scripts/authentik-audiobookshelf-sso.sh first" % os.environ["ABS_SECRET"]) print("oidc: not configured (no ABS_OIDC_CLIENT_SECRET in the credential file)" " — local auth only") else: client_id = os.environ["ABS_OIDC_CLIENT_ID"] issuer = os.environ["ABS_OIDC_ISSUER"].rstrip("/") + "/" # redirect_uri = /auth/openid/callback, and the subfolder IS the # base path the app is mounted under — taken from ABS_URL so unit and config agree. subfolder = os.environ.get("ABS_OIDC_SUBFOLDER") if subfolder is None: subfolder = urllib.parse.urlparse(os.environ["ABS_URL"]).path.rstrip("/") origin = issuer.split("/application/")[0] body = { "authActiveAuthMethods": ["local", "openid"], "authOpenIDIssuerURL": issuer, "authOpenIDAuthorizationURL": origin + "/application/o/authorize/", "authOpenIDTokenURL": origin + "/application/o/token/", "authOpenIDUserInfoURL": origin + "/application/o/userinfo/", "authOpenIDJwksURL": issuer + "jwks/", "authOpenIDLogoutURL": issuer + "end-session/", "authOpenIDClientID": client_id, "authOpenIDClientSecret": oidc_secret, "authOpenIDTokenSigningAlgorithm": os.environ["ABS_OIDC_ALG"], "authOpenIDSubfolderForRedirectURLs": subfolder, "authOpenIDButtonText": "Sign in with Authentik", # not auto-launch: the login page keeps offering the local form, which is the # break-glass path when the IdP is unreachable. "authOpenIDAutoLaunch": False, # a household user's first SSO sign-in creates their account. The role comes from # the abs_groups claim, which grants the listener role to every signer; only the # authentik admin group escalates. Revoking a listener therefore happens HERE in # ABS (disable or delete the user), never by removing an IdP group membership. "authOpenIDAutoRegister": True, "authOpenIDGroupClaim": "abs_groups", # The app's own scheme lives HERE, never in the IdP: ABS swaps it for # /auth/openid/mobile-redirect before the provider sees a redirect_uri. "authOpenIDMobileRedirectURIs": ["audiobookshelf://oauth"], } code, res = call("PATCH", "/api/auth-settings", body, token=token) if code != 200: sys.exit("refused: PATCH /api/auth-settings answered %s (%s)" % (code, res)) # Verify twice: what was stored, and what the server will honour. A config that # misses any of the eight required OIDC values is accepted with 200 and then # silently dropped at the next load, leaving authMethods=['local']. code, stored = call("GET", "/api/auth-settings", token=token) if code != 200 or not isinstance(stored, dict): sys.exit("refused: GET /api/auth-settings answered %s (%s)" % (code, stored)) for key, want in (("authOpenIDIssuerURL", issuer), ("authOpenIDTokenSigningAlgorithm", os.environ["ABS_OIDC_ALG"]), ("authOpenIDSubfolderForRedirectURLs", subfolder), ("authOpenIDButtonText", "Sign in with Authentik")): if stored.get(key) != want: sys.exit("refused: stored %s=%r, expected %r" % (key, stored.get(key), want)) if sorted(stored.get("authActiveAuthMethods") or []) != ["local", "openid"]: sys.exit("refused: stored authActiveAuthMethods=%r, expected local+openid " "('openid' is dropped when the block is incomplete)" % stored.get("authActiveAuthMethods")) code, after = call("GET", "/status") methods = after.get("authMethods") if isinstance(after, dict) else None if not isinstance(methods, list) or "openid" not in methods: sys.exit("refused: /status reports authMethods=%s after a stored config that " "looks complete — restart the service, the strategy is only armed at " "load for a config that was invalid when the server read it" % methods) print("oidc: armed (issuer %s | alg %s | claim abs_groups | redirect subfolder %r | " "client %s | secret %s chars | authMethods %s)" % (issuer, os.environ["ABS_OIDC_ALG"], subfolder, client_id, len(oidc_secret), methods)) PY exit 0