INSTALLED=$(sudo grep "^ name = 'Wolf" ...) required exactly 4 leading
spaces before name = 'Wolf...' in Wolf's own generated config.toml.
Confirmed live: Wolf's TOML writer doesn't reliably indent that way, so
this came back empty even with apps clearly installed and running,
printing "No Wolf apps installed yet". That's silently wrong in two
ways: the "Already installed: ..." message under-reports, and pressing
Enter at the apps prompt ("update mounts only") falls through to the
hardcoded `steam esde` default instead of actually refreshing whatever
was already there — so a mount fix for an already-installed app (e.g.
RetroArch) never gets applied by the Enter/no-picks path at all.
Relaxed the anchor to tolerate any amount of leading whitespace,
matching the Python injector's own already-installed check a few lines
later, which never anchored on indentation to begin with.
The esde and retroarch app profiles (both CATALOG dict copies) mounted
three subdirectories individually:
{games}/retroarch/cores:/home/retro/.config/retroarch/cores:rw
{games}/retroarch/shaders:/home/retro/.config/retroarch/shaders:rw
{games}/retroarch/overlays:/home/retro/.config/retroarch/overlays:rw
but never mounted /home/retro/.config/retroarch itself. Docker
auto-creates that missing parent directory inside the container as
root:root mode 755 (standard behavior for a bind-mount target that
doesn't already exist in the image) — the retro user (uid 1000) can
read/traverse it but not write into it. RetroArch's own entrypoint
then fails outright trying to write its default config there:
cp: cannot create regular file '/home/retro/.config/retroarch/retroarch.cfg': Permission denied
which happens on every single launch, for both apps — confirmed live
against a real box: the container starts, RetroArch dies on that cp
within ~1s, and Wolf tears the session down (the "black screen, back
to app grid" symptom, with nothing RetroArch-specific about it).
Fix: mount the parent {games}/retroarch directory itself onto
/home/retro/.config/retroarch instead of three separate subdirectory
mounts. cores/shaders/overlays already lived as the only subdirectories
under {games}/retroarch/ on the host, so this preserves the exact same
container-side paths — but now the parent is a real bind mount with no
auto-created stub in the way, and RetroArch's other generated config
(button remaps, core options, playlists, cheats, etc.) persists across
sessions too, which the old three-mount setup silently discarded.
Both sync directions used `git clone --bare` for the first clone and
`git push --mirror` for the push. A bare clone pulls in every ref the
remote advertises, refs/pull/*/head included — GitHub (and Gitea,
same behavior) exposes PR refs over the same smart-HTTP endpoint a
plain bare clone reads from. --mirror then pushes every local ref
verbatim, including those, and gets rejected: Gitea's server-side
hook (and GitHub's own PR-ref protection) reserves that namespace for
itself.
remote: error: hook declined to update refs/pull/1/head
! [remote rejected] refs/pull/1/head (hook declined)
Fix: scope the initial clone (now git init --bare + fetch, unified
with the repeat-sync path instead of a separate git-clone branch) and
the push to an explicit refs/heads/*:refs/heads/* + refs/tags/*:refs/tags/*
refspec in both directions, matching the refspec discipline the fetch
side already had. Added a defensive cleanup (delete any
refs/pull/*, refs/merge-requests/*, refs/changes/* found in the local
bare mirror before pushing) so a repo synced before this fix
self-heals on its next run instead of tripping the same hook forever.
New services/samba.sh, following the non-Docker service shape
(services/crowdsec.sh) since Samba runs natively (smbd/nmbd), not in
a container:
- Installs the samba package if missing
- Prompts to add one or more shares (path, guest vs. authenticated)
- For authenticated shares, creates a system Linux account (if one
doesn't already exist) and a separate Samba password via smbpasswd
for each user, adds them to a sambashare group
- Appends share stanzas to /etc/samba/smb.conf (tagged with a
# ubuntu-post-install:share:<name> marker for later discovery),
validates with testparm before restarting smbd/nmbd
- Opens UFW for SMB (137/138 udp, 139/445 tcp), scoped to the
detected LAN subnet by default rather than the whole internet
- Writes a docs-only README under ~/docker/samba (no compose stack)
Registered under `utilities`, with an is_installed()/install_count()
entry in setup.sh (command -v smbd, matching the glow/crowdsec
pattern for non-Docker services) and a README.md Services table entry.
Also wired as an optional nudge into services/base.sh, alongside the
existing Caddy/CrowdSec/NetBird prompts — offered during the base
install but not unconditional, since (unlike net-tools/ncdu) it needs
real input — a share path and at least one user — to do anything
useful, so it defaults to declined rather than accepted.
install_gitea() scanned WEB_PORT/SSH_PORT and published both in
docker-compose.yml but never opened either in UFW. With UFW active,
a `git clone ssh://...` against the SSH port silently drops instead
of getting connection-refused, which just hangs forever with no
error — the exact symptom reported.
The web port can be safely left off the public rule when Caddy fronts
it locally (scoped to caddy_net instead, matching every other service
here), but SSH can't be proxied through Caddy at all, so it always
gets a direct ufw allow now.
wolf-pair runs network_mode: host, so a taken 8090 fails at container
start with "address already in use" and no ports: line in
docker-compose.yml to explain why — wordpress, ntfy, and beszel all
default to 8090 too and correctly scan for a free port; wolf-pair
hardcoded it in three places (installer var, UFW rule, server.py's
bind) with no scan at all.
Now finds a free port via find_free_port, persists it in a new .env
(read back on rerun so a live install never silently moves), and
threads it into the container via WOLFPAIR_PORT so server.py binds
the scanned port instead of a literal 8090.
WP-CLI's Runner does its own restricted, line-level parsing of
wp-config.php to pull bootstrap constants without a full WordPress load,
and it only tolerates plain define(...) statements — the previous fix's
"if (file_exists(...)) { require ...; }" line (routed in via
WORDPRESS_CONFIG_EXTRA) made every wp-cli command fail with a cryptic
"PHP Parse error ... eval()'d code ... unexpected end of file",
regardless of whether the required file actually existed.
Moved the X-Forwarded-Proto shim and the PB_PRINCE_COMMAND/
DOCRAPTOR_API_KEY defines into a WordPress must-use plugin
(wp-content/mu-plugins/), which loads through WordPress's normal plugin
bootstrap rather than wp-cli's special wp-config.php pre-parser — this
sidesteps both that bug and the earlier Compose .env-interpolation bug
in the same stroke, since nothing here touches wp-config.php or .env at
all anymore. Dropped the now-unnecessary extra-config.php bind mount
from the compose file and every wp-cli invocation to match.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P1Xynq3mBwtH45f8bTDfta
wp-config.php's WORDPRESS_CONFIG_EXTRA requires extra-config.php, but the
ephemeral "docker run wordpress:cli ..." containers used for every wp-cli
call only mounted html/, not that file — loading wp-config.php there hit
a PHP fatal, which broke the wordpress:cli entrypoint's own internal
"wp help $1" probe for whether to prepend "wp". That probe failing
silently falls through to exec-ing the raw subcommand as a literal binary
("core: not found") instead of running it through wp-cli at all.
Fixed by: bind-mounting extra-config.php into every wp-cli invocation too,
guarding the require with file_exists so a missing mount can't fatal
wp-config.php again, and spelling "wp" out explicitly in the wpcli
wrapper functions rather than depending on the entrypoint's own
bootstrap-dependent auto-detection. Updated the manual-retry command
printed on failure and the README's wp-cli example to match.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P1Xynq3mBwtH45f8bTDfta
"if ! docker compose build && docker compose up -d" only negates the
build command's exit status, so on a normal successful build the whole
&&-chain short-circuited false and docker compose up -d never executed —
no containers started, no pressbooks_net network created, so every
following wp-cli call ("docker run --network pressbooks_net ...") failed
with "network pressbooks_net not found". Split into two separate checks.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P1Xynq3mBwtH45f8bTDfta
Dedicated WordPress Multisite install (never shared with wordpress.sh,
since Pressbooks requires a fresh network) with a custom image adding
mod_rewrite/AllowOverride, Ghostscript/ImageMagick/poppler-utils for the
cover generator, and an optional PrinceXML install for PDF export
(DocRaptor offered as a SaaS alternative). Chapters use WordPress's own
block editor, which already supports drag-and-drop image placement.
Gated by Authelia SSO via the standard forward_auth pattern.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P1Xynq3mBwtH45f8bTDfta
services/homebox.md gets auto-appended to Homebox's generated README
by write_readme(). Covers the installer's own opt-in fix plus a fully
manual UI walkthrough and a direct curl/API path, for anyone who'd
rather not paste a token into the installer or who's confirmed a
multi-collection setup isn't worth chasing through it.
UI navigation (collection selector -> Collection options -> Entity
Types tab, /collection/entity-types) confirmed against Homebox's own
frontend source rather than guessed.
Some Homebox collections never get their default Location/Item entity
types seeded (a known upstream bug), leaving the Create dialog's type
dropdown empty and every creation attempt failing with "Please select
an entity type".
_homebox_offer_entity_type_fix() repairs this without ever storing a
credential: entity types are scoped per collection with no
unauthenticated API access, so it prompts for a pasted API token at
the moment it runs (used once, never written to .env or disk, same
model as Immich's own admin-API-key prompt), then seeds the two
default types only if none already exist. Wired into both the
fresh-install and update paths.
_authelia_provision_oidc_client() hardcoded openid/profile/email as the
only scopes a registered client could ever request, but Homebox's own
Authelia integration needs 'groups' too — requesting it without it being
in the client's own scopes allowlist made Authelia reject every login
with invalid_scope, even though the server supports 'groups' generally.
Add an EXTRA_SCOPES positional arg (space-separated, right after
REQUIRE_PKCE) that only Homebox's caller populates ("groups"); every
other existing caller passes "" and gets a byte-for-byte unchanged
client registration.
_homebox_offer_authelia_oidc() automates Homebox's own native OIDC support
(real env vars, not paste-in instructions) — confirmed the exact variable
names and redirect path against homebox.software's own OIDC docs and
authelia.com's Homebox integration page, not guessed. Needs PKCE, unlike
Mealie/ActualBudget.
The stock compose template listed env vars individually in `environment:`
rather than using `env_file: .env` — added to the template, and patched
onto any pre-existing install's compose file the first time this offer
runs, or the OIDC vars written to .env would never actually reach the
container.
_homebox_offer_disable_local_login() is the separate, gated "replace
local login entirely" step (HBOX_OPTIONS_ALLOW_LOCAL_LOGIN=false +
HBOX_OIDC_AUTO_REDIRECT=true), same "have you tested it first" pattern as
Mealie/Beszel.
Confirmed live: install_frigate()'s fresh-install path overwrote a
working, hand-crafted docker-compose.yml (Frigate + mosquitto +
frigate-notify) with zero backup, because that file's shape didn't match
what frigate.sh's own "existing install" detection knew how to recognize.
Every service's own detection is a judgment call about what counts as
"already installed" and can miss a real setup built outside this repo's
conventions.
lib/common.sh gains backup_if_exists(FILE) — copies FILE to
FILE.bak.<timestamp> if it exists, no-ops otherwise (including DRY_RUN).
Applied before every service's own `cat > docker-compose.yml`/`cat > .env`
write across all 60 services that do one (115 call sites), plus a matching
standalone-mode stub added to every service's own bootstrap block, same
convention already used for port_in_use/find_free_port. This doesn't
replace a service's own update/fresh-reinstall detection — it's the safety
net underneath it, so a wrong detection costs a .bak file to restore from
instead of the original silently disappearing.
Also fixes the actual gap that surfaced this: services/frigate.sh's
Authelia offer only checked for Authelia installed locally on Frigate's
own box, which is never true for a dedicated NVR box with no local Caddy
either (the common shape — Caddy lives elsewhere, snippet-generation mode
already handles that). Now offers Authelia protection unconditionally and,
when Authelia isn't local, asks whether it lives on the same machine as
Caddy (still "import authelia", since that's local to wherever Caddy ends
up) or on a genuinely separate third machine (the explicit
header-pinned forward_auth form, per CLAUDE.md's "forward_auth to a remote
Authelia" note, needed because a bare authelia:9091 shortcut only works
one hop).
DNS propagation lag, TLS cert readiness, and a stale SiteURL all follow
directly from a migration that also changes domains, on top of the
DB-import device-registration cause already documented.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AddPmva5bfrUW3MoPriq21
Covers the migrated-from-PikaPods case: stale device registrations
carried over by the DB import, server-to-push-proxy connectivity,
OEM battery optimization, and the push-content setting — the likely
causes when only some Android users stop getting background pushes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AddPmva5bfrUW3MoPriq21
_authelia_bulk_assign_group() (menu option 17) picks several users and one
target group in a single step, repeatable for multiple batches in one
visit (e.g. "1 4 5 6" -> internal, then "2 3 7 8" -> external1) — the
missing third combination alongside the existing per-user (option 6) and
per-group (option 16) toggles, which only handle one user or one group at
a time respectively. "Internal" clears every outside-access group instead
of assigning one, since internal access is the absence of a group.
_authelia_describe_user_access() is a new shared one-line summary (admin /
internal / group names) used both here and in edit_authelia_user()'s own
listing, so current access is visible right where you're about to change
it instead of requiring a separate trip to option 15's report.
Verified end-to-end against a mock users.yml: batch 1 correctly cleared
an existing group from 4 users, batch 2 correctly added a brand-new group
to a different 4, with the listing reflecting each change before the next
batch starts.
Pure wording change, no behavior difference — internal already meant
exactly this (any registered Authelia user, no group) before the rename.
Also brings CLAUDE.md's description of the outside-access/admin-bypass
feature up to date; it still described the pre-generalization one-group-
per-service shape from earlier in this branch.
Printed "hub Settings -> Auth providers", which doesn't exist. The real
location is PocketBase's own admin panel underneath the hub
(/_/#/settings -> unhide collection edit controls -> edit the "users"
collection -> Options tab -> OAuth2), confirmed against beszel.dev's
OAuth guide directly. Fixed in both beszel.sh's own offer and authelia.sh's
generic OIDC menu preset.
Confirmed live: offering DISABLE_PASSWORD_AUTH/ALLOW_PASSWORD_LOGIN in the
same breath as printing the Authelia paste-in values lets an admin say yes
before actually pasting those values into the app's own settings and
testing the button — leaving neither login path working (password form
gone, OAuth provider never actually finished on the app's side).
Both are now their own function, only reachable on a later run (Beszel:
independently after the SSO offer; Mealie: from the "already configured,
not reconfiguring" branch), and gated behind an explicit "have you already
logged in successfully via the Authelia button?" confirmation before the
disable prompt is even offered.
Group membership was previously only editable per-user (option 4's user
menu, option 6 toggles that one user's groups) — no way to pick a group
and see/toggle its members directly. Adds the reverse entry point: pick
a group, then toggle which users are in it. Same _authelia_toggle_group()
underneath, just entered from the other direction.
_authelia_provision_oidc_client gains an optional PKCE flag (new 5th
positional arg; every existing caller updated to pass "n", producing an
identical client block to before) — Audiobookshelf and Beszel's own
Authelia integration docs both require require_pkce/pkce_challenge_method,
which Authelia doesn't turn on by default.
immich.sh: _immich_offer_authelia_oidc() is real server-side automation,
not just paste-in instructions — confirmed the exact system-config "oauth"
JSON field names against Immich's own config-file.md and source (not
guessed, closing out the "needs one more verification pass" note this
repo's own CLAUDE.md already had on file). GET/PUT exchange the whole
config object, so it round-trips everything else unchanged. Needs an
admin API key that doesn't exist until first web-UI visit, so it's wired
into both the fresh-install path and the "update" rerun path.
audiobookshelf.sh, beszel.sh: both apps' OIDC config is UI-only (checked
against audiobookshelf.org and beszel.dev directly — no config API or env
var for the provider fields), so their new offers automate the Authelia
side and print exact paste-in values. Beszel also gets a real, separate
DISABLE_PASSWORD_AUTH/USER_CREATION toggle to fully replace its login,
gated behind a warning to register a working account first.
Also adds Audiobookshelf and Beszel as presets in authelia.sh's own
generic "Register another app" menu, and updates CLAUDE.md's OIDC
verification table to match reality (Immich now wired, Audiobookshelf
was wrongly listed as "high-confidence no", Beszel added).
The old note pointed at an OPENAI_MODEL env var for Mealie's "import
recipe from photo" feature. Checked against docs.mealie.io directly:
Mealie moved AI provider config off env vars entirely — it's a live
Group Settings > AI Providers UI setting now (base_url/api_key/model,
with a separate toggle for which provider handles image recognition).
Also spells out how to actually reach this stack's Ollama from Mealie's
separate compose project (host-published port, not a shared network).
authelia.sh: menu option 15 lists every outside-access group with its
site membership (from access_control.rules, excluding each group's own
deny-elsewhere rule) and user membership (from users.yml) in one place —
previously only visible by grepping both files by hand.
mealie.sh: _mealie_offer_authelia_oidc now offers to set
ALLOW_PASSWORD_LOGIN=false (hides Mealie's own login form) and
OIDC_AUTO_REDIRECT=true (skip the login page, go straight to Authelia),
both confirmed against docs.mealie.io rather than assumed. Off by
default since it's a real access-control change, not just an additive
SSO button — anyone without an Authelia account loses their login path.
_authelia_scope_access previously derived a throwaway "<service>-only" group
every time it ran, so scoping two different sites to the same set of people
meant either duplicating membership by hand or hitting a false "already
scoped" early-return that silently skipped adding the second site's own
rule. Now it offers existing groups by number (any site can join one), lets
a new name be typed freely (e.g. "customer1"), and the already-scoped check
is keyed to the (domain, group) pair instead of the group name alone.
Reframes the access question as native (default, unrestricted) vs. outside
access (a named group) per the AD-style users/groups mental model, and adds
menu option 14 to rename an existing group everywhere it's referenced
(access_control.rules subjects + every member's users.yml entry). The
"-only" suffix stays internal only — every other function that already
keys off it (reporting, per-user group toggle, unprotect cleanup) is
untouched.
Adds a "subject: group:admins" rule ahead of every domain's other rules, so
admins always match first regardless of any per-service scoping (existing or
future) on that domain — a group's deny-elsewhere rule can no longer catch an
admin even if they're accidentally added to that group later.
- install_authelia and add_authelia_domain bake the rule in at creation time
- _authelia_scope_access retrofits it just-in-time before inserting its own
deny-elsewhere rule, and anchors that rule below it instead of at the top
- new menu option 13 (_authelia_ensure_admin_access_everywhere) backfills it
across every domain on an install that predates this
- remove_authelia_domain cleans the rule up too when a domain is removed,
and its domain picker dedupes since two rules now share one domain string
Lets accounts (users.yml, portable argon2id hashes included) and 2FA/session
state (data/db.sqlite3 + the storage_secret needed to decrypt it) round-trip
through a reinstall without resetting passwords or forcing everyone to
re-enroll their authenticator.
install_authelia()'s and add_authelia_domain()'s "subdomain for the
login portal" prompts concatenated whatever was typed directly with
the apex domain (AUTHELIA_PORTAL_SUBDOMAIN + "." + AUTHELIA_DOMAIN),
with no guard against someone typing the full portal domain they
actually want (e.g. "authelia.mydomain.com") instead of just the
subdomain label ("authelia"). That produces a silently broken,
doubled hostname like "authelia.mydomain.com.mydomain.com" -- which
never matches a real request, so Caddy falls through to some default
response instead of ever reaching real Authelia policy evaluation.
Confirmed live: this is exactly what happened on a real box, and
explains a much bigger symptom than the obviously-wrong hostname alone
would suggest -- every forward_auth-gated site on the instance
silently bypassed Authelia entirely, not just requests to the portal
itself, since the forward_auth subrequest to the (wrong) portal URL
never got a real answer either.
Both prompts now detect and strip an accidentally-included apex suffix
(with a one-line notice), and fall back to "auth" if someone enters
the bare apex domain itself (which can't work as the portal -- it
would collide with the wildcard rule protecting every other domain).
Verified against the exact doubled-domain input, a bare-apex input,
and two ordinary short-label inputs before shipping.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
Two changes:
1. Fixed several places that still described the login portal as
literally "auth.<domain>" in user-facing text, even though the
actual subdomain has been prompt-configurable since the last
session's fix (AUTHELIA_PORTAL_SUBDOMAIN) -- the prose just never
caught up. add_authelia_domain()'s intro, install_authelia()'s
generated README, and remove_authelia_domain()'s note now describe
the portal as "you'll pick the subdomain" instead of asserting a
fixed prefix that was no longer true.
2. Every menu in this file now uses a consistent 0-to-exit/cancel
convention instead of each one doing its own thing (a numbered
"leave as-is" as the highest number, blank-to-cancel, no cancel
option at all, etc.):
- Top-level "Authelia already exists" menu: "Leave as-is" moved
from option 12 to 0 (still the default).
- _authelia_add_oidc_client's app-choice menu: added explicit
"0) Cancel" (previously a blank Enter silently defaulted to
"Other/custom app" -- surprising, now it cancels instead).
- _authelia_manage_one_user's per-user action menu: "Done" moved
from 8 to 0.
- edit_authelia_user's user-selection list and its service-group
toggle sub-list: "blank to cancel" became "0 (or blank) to
cancel", explicit and documented instead of implicit.
- _authelia_protect_site / _authelia_unprotect_site: added
explicit 0-to-cancel (previously a literal "0" typed would have
been treated as a domain name, not a cancel).
- _authelia_remove_oidc_client_menu: same explicit 0, default
changed from blank to "0".
- remove_authelia_domain: was free-text domain entry against an
unnumbered list; now a proper numbered list with 0 to cancel,
consistent with every other domain/site picker in this file.
- _authelia_scope_access: renumbered so "0) Any Authelia user"
(the safe no-op default) takes the 0 slot, "1) Specific users
only" is the one real choice.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
Every invocation of setup.sh (bare, --list/--status, or with a service
name) now ensures /usr/local/bin/post-install exists and execs this
checkout's setup.sh by its real resolved path -- idempotent (only
writes when missing or pointing somewhere else) and silent except for
a one-line notice the first time it's actually created. Previously
this required cd'ing into the repo (or a manually-created wrapper) on
every box separately; now it's automatic on first run, no separate
setup step.
A plain symlink wouldn't have worked here: setup.sh finds its own
directory via ${BASH_SOURCE[0]}, which bash doesn't resolve through
symlinks, so a symlinked invocation would have set HERE to the
symlink's own directory instead of the repo's.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
_authelia_add_oidc_client()'s "what domain is this app on" prompt only
ever took typed text (with a guessed SITE_DOMAIN-based default) even
though _authelia_protect_site already offered a numbered pick-from-
Caddy-or-type-a-domain UX for the equivalent question elsewhere in
this same file -- an inconsistency a user flagged directly after
registering Mealie's OIDC client and getting a plain text prompt where
they expected the same numbered list.
Factored the shared part into _authelia_pick_domain(): lists this
box's local Caddy sites by number, or accepts a typed domain
(including one not on this box's Caddy at all). Echoes the chosen
domain on stdout with the listing itself on stderr, verified separable
under $(...) capture before wiring it in. Used now by the OIDC domain
prompt; _authelia_protect_site/_authelia_unprotect_site keep their own
inline listing since they additionally annotate each site's current
protection status, which this shared version doesn't need to know
about.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc