_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
Three related fixes so undoing an Authelia change never requires
hand-editing configuration.yml or the Caddyfile:
- _authelia_add_oidc_client() had its own redundant duplicate-client-ID
check that dead-ended with "pick a different app, or edit that entry
by hand" -- even though _authelia_provision_oidc_client (called a
few lines later in the same function) already handles that exact
case safely by replacing the stale registration. Removed the
redundant check; the flow now always reaches the safe path. This was
the actual blocker in the reported "client with ID 'actualbudget' is
already registered" error -- re-registering the same app a second
time was never actually broken, just gated by dead code.
- New option 6, _authelia_remove_oidc_client_menu(): lists registered
OIDC clients by ID and name, removes one via the existing internal
_authelia_remove_oidc_client() helper (previously only reachable
from the replace-on-duplicate path, never exposed directly).
- New option 11, _authelia_unprotect_site(): reverse of option 10
(_authelia_protect_site). Removes a local site's "import authelia"
or forward_auth block from its own Caddy block and reloads Caddy;
for a domain on a different box's Caddy, cleans up its access-
scoping rules here (the actual gate needs removing on that box by
hand, same one-way limitation option 10 already has in reverse).
Also removes any _authelia_scope_access rules for the domain, found
by the same "<domain>-only" group name convention, verified against
a synthetic multi-domain configuration.yml before shipping so an
unrelated domain's rules sharing the same "*.<apex>" line are left
untouched. Both the local-block removal (import authelia one-liner
and multi-line forward_auth block shapes) and the access-rule
removal were tested against realistic fixtures first.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
Every individual service so far offers its own "Protect X with
Authelia SSO?" prompt on install, but there was no way to gate an
arbitrary existing site from Authelia's own menu -- especially useful
for a site on a DIFFERENT box's Caddy than the one Authelia runs on,
this repo's own recurring case (a DigitalOcean droplet's site,
protected by an Authelia instance on a separate IONOS box).
New option 9, _authelia_protect_site(): lists this box's own local
Caddy sites by number (flagging ones already protected), or accepts a
typed domain that isn't on this box's Caddy at all. A local site gets
"import authelia" inserted as the first line of its existing block --
before reverse_proxy, same ordering rule as everywhere else in this
codebase, since Caddy runs directives in the order written and an auth
check after reverse_proxy never runs at all. A remote site can't be
edited from here, so it prints (and saves to caddy-snippets/) the
remote-hop-safe forward_auth block that box's own Caddyfile needs
instead, with the portal's actual domain read back from
configuration.yml rather than assumed. Either way finishes by calling
_authelia_scope_access for the domain, so protecting a site and
restricting who can reach it happen in one pass.
Verified the site-listing regex, insertion, idempotency detection, and
remote-domain/portal lookup against synthetic Caddyfile/configuration.yml
fixtures before shipping.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
_authelia_scope_access() (services/authelia.sh) already works for any
Authelia-protected service, forward_auth-gated or OIDC alike -- it's
just never been called from security-dashboard.sh on either the
domain-takeover path (_secdash_offer_asterisk_domain) or the plain
separate-subdomain path, so every domain this dashboard ever protected
defaulted to "any Authelia user", with no way to restrict it to
specific people. That's why the Authelia menu's "Promote to a
specific service's access group" reported no scoped groups existing
yet even after protecting this dashboard with Authelia.
_secdash_configure_caddy() now offers scoping right after the domain
is Authelia-protected (guarded on EXTRA_BLOCK being non-empty, so
Basic-Auth-only or no-auth setups aren't offered a scoping question
for a gate that doesn't exist), guarded by declare -F for standalone
runs where authelia.sh was never sourced. Runs whether the Caddy block
was just freshly written or already existed, so re-running the
installer on an already-configured domain still offers it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
install_authelia() and add_authelia_domain() both hardcoded "auth." as
the login portal's subdomain prefix everywhere -- configuration.yml's
authelia_url, the Caddy portal block/domain, generated README/OIDC
text. No prompt ever offered anything else, despite this repo
otherwise treating "auth.<domain>" as just this one instance's own
choice, not a protocol requirement.
Both now prompt for the portal subdomain (default "auth", so existing
behavior is unchanged for anyone who doesn't care) and use the actual
chosen value throughout. Every function that operates on an EXISTING
domain (remove_authelia_domain, _authelia_add_oidc_client) now reads
the real portal domain back from that domain's own session.cookies
authelia_url entry instead of assuming "auth.<domain>" -- matching the
same read-back pattern _authelia_add_oidc_client already used for the
apex domain itself, and _authelia_provision_oidc_client already used
for the portal URL. _authelia_remove_caddy_portal_block now takes the
portal's full domain directly rather than reconstructing it, so
removing a domain whose portal used a custom prefix actually finds and
removes the right Caddy block.
Also fixed a real, separate small bug found while in here: the
primary portal's Caddy log path was hardcoded to a generic auth.log
(collides across instances/domains) instead of following every other
site block's own <domain>.log convention.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
_asterisk_configure_caddy_public() used to reverse-proxy Asterisk's own
web admin at its public domain, optionally gated by local or remote
Authelia, flipping WEB_ADMIN_AUTH_DISABLED=true in .env to hand auth
off to it. That coupling was the root cause of a real live exposure:
a box where Authelia protection was accepted once, but the Authelia
import/forward_auth block itself later went missing from the Caddyfile
(e.g. lost on a restore), was left with the web admin's own login off
and nothing else gating it -- extension/device data reachable with no
password at all. A remote Authelia's forward_auth also proved fragile
in practice for something that only ever needed to keep a domain's
cert alive (DNS/routing/access-rule mismatches spanning two boxes,
hard to diagnose from either one alone).
This domain now just serves a minimal keep-alive page (a bare "OK" 200
response) so Caddy can still issue/renew the SIP TLS cert -- cert
issuance only needs Caddy to own the site block, it's unrelated to
what the block serves. Auth is now optional Basic Auth handled
entirely inside Caddy itself, no external subrequest, so it can't fail
this way. Asterisk's own web admin is no longer exposed publicly by
this function at all -- reachable only via the CLI:
docker exec -it <container> easy-asterisk
The Security Dashboard's own domain-takeover offer
(_secdash_offer_asterisk_domain in security-dashboard.sh) is the
supported way to put something meaningful on this domain instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
add_authelia_domain() (menu option 1) had no reverse operation -- once
a domain was added there was no way to undo it short of hand-editing
configuration.yml and the Caddyfile. remove_authelia_domain() (new
option 2) does the reverse cleanly: removes the access_control.rules
entry, the session.cookies entry, and the auth.<domain> Caddy portal
block for one domain, verified against a synthetic multi-domain
configuration.yml before shipping. Warns loudly that any service still
pointed at the removed domain will stop authenticating, and requires
confirmation before touching anything.
The menu's own text now also flags the likely real mistake this
surfaces: adding a domain that's actually just a SUBDOMAIN of an apex
already on the instance creates a *.subdomain.apex wildcard rule that
doesn't match the bare subdomain itself, plus a redundant separate
auth.subdomain.apex portal -- when the subdomain was already covered
by the existing apex's own wildcard rule and portal all along.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
Asterisk's own web admin was only Caddy-fronted at its own domain so
Caddy could issue it a trusted TLS cert for SIP -- cert issuance only
needs Caddy to own that domain's site block, it's unrelated to what
reverse_proxy target the block forwards to. _asterisk_configure_caddy_public
also never rewrites an existing site block on a repeat run, so a box
where WEB_ADMIN_AUTH_DISABLED got set true (from an earlier "protect
with Authelia" answer) but the Authelia import itself never landed or
got lost on a restore was stuck silently unauthenticated with no
reconfigure path ever revisiting it -- confirmed live: a real box was
found exposing its extensions/device list with no login at all.
_secdash_offer_asterisk_domain() now offers, whenever Security
Dashboard's Caddy setup runs (fresh install or reconfigure) and
Asterisk already has a public domain, to serve the dashboard there
instead of a separate subdomain: removes Asterisk's old site block for
that domain, rebuilds it fresh under this dashboard's own (always-on)
Authelia gate, and re-enables Asterisk's own web admin login in .env
as defense-in-depth now that its port isn't published at all.
Declining falls through to the normal separate-domain prompt
unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
New installs now name the container "asterisk", matching every other
service's container_name == service name convention, instead of
reusing the vendored easy-asterisk CLI tool's own name (which stays
/usr/local/bin/easy-asterisk inside the container, unrelated and
unchanged).
An existing "easy-asterisk" install is never silently renamed: every
place that resolves the container name (_asterisk_resolve_layout in
asterisk.sh, plus the duplicated copies in security-dashboard.sh,
sms-inbound.sh, pstn-trunk.sh, and tools/pstn-test-check.sh's docker ps
detection) now reads it from the box's own docker-compose.yml instead
of assuming it, falling back to "asterisk" only when there's no
existing install to read. Migrating a live box to the new name is a
one-time manual action (edit docker-compose.yml's container_name for
Asterisk and its coturn sidecar, docker compose down + up -d); every
sibling service then picks it up automatically on its next run.
The DigitalOcean-droplet layout (asterisk-digital-ocean directory,
easy-asterisk-do container) is untouched by this - that naming stays
exactly as documented for pre-merge droplet installs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
keep_alive_interval is a type=global pjsip.conf option, not a
type=transport option -- it never existed on [transport-tls] on any
Asterisk version. The IONOS TLS-keepalive mitigation was inserting it
there, which made sorcery reject the whole transport-tls object
("Could not find option suitable for category 'transport-tls' named
'keep_alive_interval'"), silently killing TLS SIP entirely instead of
just adding a keepalive.
Both _asterisk_patch_keepalive_vendor_files (deployed vendor copies)
and _asterisk_ensure_live_keepalive (live pjsip.conf) now target
[global]/type=global, and both self-heal a box that already picked up
the bad placement by removing it from [transport-tls] first.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
The stack health check's Security Dashboard/sms-inbound/ntfy checks only
had visibility into this box's own local Caddyfile — but all three can
legitimately be fronted by a Caddy (and Authelia) on a completely
different box instead, the same remote-Caddy pattern sms-inbound.sh and
ntfy.sh's own installers already support via CADDY_MODE/CADDY_REMOTE_HOST.
A site explicitly configured for remote Caddy was getting a false "Caddy
has no site block for it" for each of them, with a fix offer that would
have been actively wrong: adding a redundant local Caddy block for
something deliberately fronted elsewhere.
Now resolves the same site-wide CADDY_MODE the affected services'
installers themselves use before treating "not found locally" as a real
issue — only counts it, and only offers a fix, when the site is actually
in local Caddy mode. Remote (or no-Caddy) mode gets a plain informational
line instead: not wrong, just not something this box can verify.
Verified: default/local mode still flags a genuinely unwired dashboard as
an issue with a fix prompt; CADDY_MODE=remote (even with a local Caddy
directory also present) correctly downgrades the same finding to
informational with no prompt and no issue counted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
"update" mode's whole promise is leaving already-configured things
alone — but that assumption silently breaks when something was
configured but never fully wired up, and update never re-asks the
questions that would reveal it. This session hit three separate
instances of exactly that on one droplet revert: a domain set with no
Caddy block, a Caddy block with no synced TLS cert (transport-tls fails
to bind — "Unable to retrieve PJSIP transport 'transport-tls'", breaking
every call), and a baked-in external IP left over from before the box
moved. sms-inbound and (potentially) Security Dashboard/ntfy can have
the identical "domain set, nothing serving it" gap with no way to
discover it either, since their own update modes don't re-ask.
_asterisk_run_stack_health_check(), called every "update", replaces the
narrower Caddy-only check added last time:
- Compares pjsip.conf's baked external_signaling_address against this
box's actual current public IP; offers to rewrite it and restart.
- Checks Asterisk's own DOMAIN_NAME has both a Caddy site block and a
matching TLS cert in the container; offers to fix each independently.
- Checks Security Dashboard / sms-inbound / ntfy (whichever are
installed) for a matching Caddy site block, via a new lib/common.sh
helper (caddy_domain_for_upstream) that finds the block without
needing to already know the domain — none of these three services
persist it anywhere. Points at that service's own "Full reinstall"
(the only mode that re-asks) since fixing their config isn't this
file's to script.
The cert-sync fix needed a non-interactive hook into the vendored
easy-asterisk CLI, which only exposed it as an interactive menu item
(Server Settings -> Force re-sync Caddy certs). Added a --sync-caddy-cert
flag via _asterisk_patch_cert_sync_cli(), patching the deployed vendor
copy the same way _asterisk_patch_voicemail_vendor_files and friends
already do — never vendor/ in git.
Every check runs unconditionally (never opt-in, so a gap is never missed
by nobody thinking to ask); every fix is individually opt-in and named
as a real config change, unlike the rest of "update"'s no-side-effects
default.
Also factored the DO-metadata/ifconfig.me/hostname-I public-IP detection
chain (previously duplicated 3 times) into _asterisk_current_public_ip().
Verified: caddy_domain_for_upstream against a multi-block Caddyfile
(distinguishes same-prefix upstreams correctly); the full health check
against fake docker/curl across every combination (all wired, IP
mismatch declined/accepted, cert mismatch declined/accepted, dashboard
unwired, sms-inbound wired vs. placeholder-domain, multi-instance ntfy
with one wired and one not); and _asterisk_patch_cert_sync_cli's
idempotency + resulting syntax against a real copy of the vendor script.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
"update" mode never re-asks the domain/networking/Caddy questions, on the
assumption there's already Caddy/Authelia config in place to leave alone.
That assumption breaks for an install where a domain was set at some
point (DOMAIN_NAME in .env) but Caddy never actually got a site block for
it — declined at install time, DNS wasn't ready yet, or Caddy was
reinstalled/reset separately since. Previously the only way back was a
full reinstall, which re-generates a dedicated coturn container with new
TURN credentials (every already-configured phone needs its QR re-scanned)
— a lot of blast radius just to add one missing Caddy block, and enough
that reaching for it risks the extensions/voicemail data a "fresh"
reinstall can also wipe if the wrong prompt is answered.
"update" mode now detects this specific gap (domain set, no matching
Caddyfile block) and offers to run _asterisk_configure_caddy_public()
right there — the same function "fresh" installs use, but it only ever
touches the Caddyfile and .env's WEB_ADMIN_AUTH_DISABLED line, never
coturn/extensions/anything else "update" already promises not to touch.
Verified in isolation: offers and calls the fix when the domain is set
with no matching Caddyfile block, stays silent when a block already
exists.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
Confirmed live: neither this repo nor the vendored easy-asterisk script
ever sets a PJSIP endpoint's `mailboxes=` field. add_device()'s own
device_config template never writes it, and write_voicemail() only ever
touched voicemail.conf — so recording a voicemail worked fine
(voicemail.conf + the dialplan's VoiceMail() call), but no phone ever
actually subscribed to be told about it, regardless of whether the
voicemail flag was on. Matches the exact symptom of "voicemail records
fine, but no notice comes up on the phone."
Add _ea_set_endpoint_mailboxes(), called from write_voicemail(): adds/
updates mailboxes=<ext>@default in that extension's PJSIP endpoint stanza
when voicemail is enabled, removes it when disabled, and reloads
res_pjsip so it takes effect immediately. Bounded to just the
type=endpoint stanza (pjsip.conf reuses the same [ext] bracket name for
type=endpoint/type=auth/type=aor) the same way lib/common.sh's
_remove_caddy_site_block is bounded for Caddy blocks — verified against a
two-device pjsip.conf that editing one extension's mailboxes= never
touches its own auth/aor stanzas or another extension's stanzas, that a
repeat enable doesn't duplicate the line, and that disabling removes it
cleanly.
Existing extensions with voicemail already enabled won't get this
retroactively — the Extensions tab's voicemail toggle has to actually run
again (off then back on) to apply it, since this only fires on the
enabled/disabled transition itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
When the "Public domain for the webhook" prompt was left blank (DNS not
ready yet, or just missed), the installer built the Forward-to-URL as
literal https://<your-domain>/sms/... and persisted that placeholder to
settings.env as if it were real. "Update" mode never re-prompts for the
domain (by design — it's meant to leave already-configured settings
alone), so every later re-run silently re-served the same unusable
placeholder, with nothing indicating anything was wrong. A DID provider
(Anveo) correctly rejects it — it isn't a resolvable hostname.
- Only build FORWARD_URL when a real domain was entered; leave it empty
otherwise instead of substituting the placeholder.
- Fresh-install summary and README now say plainly that setup isn't
complete and how to finish it, instead of printing an empty/bogus URL.
- Update-mode now detects a missing/placeholder domain and tells you to
re-run with "Full reinstall" to be asked again, instead of reporting
success with a broken URL.
Verified with a direct test of _sms_write_readme() and the FORWARD_URL
construction for both the blank- and real-domain cases.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
Follow-up to Frigate's Authelia integration: both of these can also skip
their own login entirely once Authelia is doing the gating, each with a
different trust model appropriate to what the app actually supports.
- gitea: new _gitea_offer_reverse_proxy_auth(), a second Authelia
integration alongside the existing OIDC "Sign in with Authelia" button.
Enables Gitea's own ENABLE_REVERSE_PROXY_AUTHENTICATION so it auto-logs
in from a trusted Remote-User header — no click, no separate Gitea
session to expire on its own. Trust is IP-range based
(REVERSE_PROXY_TRUSTED_PROXIES), computed from caddy_net's real subnet
the same way ufw_allow_from_caddy_net does; refuses to enable the
feature at all if that can't be determined rather than fall back to a
permissive default — Gitea's own Docker image has shipped an unscoped
default before (GHSA-f75j-4cw6-rmx4, any IP could impersonate any user).
Rewires Gitea onto caddy_net and re-points Caddy at gitea:3000, since it
previously only reached Caddy via its published host port. Gitea's own
login stays available as a fallback, so unlike Frigate there's no
"native login off with nothing gating it" state to guard against.
- uptimekuma: sets DISABLE_AUTH=true only once Caddy's "import authelia"
gate is confirmed in front of it. Uptime Kuma already joined caddy_net
unconditionally, so this only needed the env var plus moving the
Authelia-gated Caddy call earlier (before docker-compose.yml is
written); the existing unconditional call at the end now only runs as a
fallback when the Authelia path wasn't used or wasn't completed. Kuma's
DISABLE_AUTH has no IP-scoping or secret check left once set — the
strictest of the three to get the ordering right on, since a mistake
here means wide open, not just spoofable.
Verified with a local test harness (fake Authelia/Caddy/docker-network
state): both the happy path and the "Caddy declined" safety fallback
produce the expected docker-compose.yml/.env/Caddyfile output for each
service, and Gitea's subnet-detection refusal + idempotent-rerun guard
were exercised directly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
Frigate has its own built-in login separate from Authelia's session, so
just adding `import authelia` in front of it (the pattern used for
no-built-in-auth services) would leave two independent logins stacked,
defeating the point of Authelia's "remember me" on mobile. Frigate has a
`proxy` auth mode built for exactly this — trust Remote-User/Remote-Groups
from an upstream forward_auth proxy and disable its own login entirely.
- Extend configure_caddy_for_service() with an optional 5th arg for
sub-directives inside the reverse_proxy block itself (header_up), needed
to pin an X-Proxy-Secret header so Frigate's proxy-auth trust can't be
spoofed by a request reaching its published port directly, bypassing
Caddy/Authelia. Backward compatible — every other caller is unaffected.
- services/frigate.sh: prompt to protect with Authelia when installed;
wires import authelia + the X-Proxy-Secret header_up into Caddy, and
only writes config.yml's auth.enabled: False + proxy block once Caddy
actually confirms it's fronting the domain (never disables the native
login with nothing else gating access). Reuses the secret across
reinstalls instead of rotating it. Calls _authelia_scope_access() so
access can be restricted to specific users instead of every Authelia
account. Fixed a latent bug in the standalone-mode Caddy stub where the
auth block was placed after reverse_proxy instead of before it (dead
code — the same "Authelia never prompts" bug class CLAUDE.md documents
for the real helper).
- CLAUDE.md: document the new configure_caddy_for_service parameter and
Frigate's hybrid built-in-auth/forward_auth pattern.
Verified end-to-end against a local test harness (fake Authelia/Caddy
dirs): config.yml, .env, and the generated Caddyfile block all agree on
the shared secret and header names, auth is skipped cleanly when Caddy
isn't configured, and the secret is reused on a second run.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
User feedback: wanted chromaDB/rag-server/mcp-server included in the
optional-services picker added last commit, not just Gitea/Portainer/
Kiwix/InvokeAI/ComfyUI/Aider. Confirmed against the compose file's
depends_on chain before adding: open-webui only depends_on ollama (its
OLLAMA_BASE_URL connection works standalone), so none of these three are
actually required for regular chat — only Open WebUI's separate RAG tab
(routed through rag-server) and MCP tool-calling need them. Mealie's own
Ollama usage never touches this stack at all.
Bundled chromadb+rag-server+mcp-server as one option (7), not three
separate numbers — mcp-server depends_on rag-server depends_on chromadb,
so stopping only one of the three would leave the others running against
a dead dependency instead of a clean stop. Also added a cascade for the
existing kiwix option: mcp-server depends_on kiwix too (not just
rag-server), so stopping kiwix without also stopping mcp-server has the
same problem — now handled automatically with a dedup pass in case both
the kiwix cascade and option 7 add mcp-server to the stop list.
Verified all four cases in isolation: kiwix-only correctly cascades to
mcp-server, option 7 alone stops the right three, choosing both dedupes
to one clean list, and unrelated choices (gitea/portainer) are unaffected.
User feedback: local-ai-setup.sh always brings up the entire stack
unconditionally (Gitea, Portainer, Kiwix, InvokeAI, ComfyUI, Aider,
alongside the core Ollama/Open WebUI/ChromaDB/RAG/MCP) with no way to opt
out — e.g. Gitea when you already run git elsewhere, or Portainer when you
manage Docker some other way.
Didn't touch local-ai-setup.sh's own compose generation for this (it's
vendored upstream code, and other services reference these by container
name/network in ways that would need individual auditing to make safely
conditional). Instead, added a post-install picker in the wrapper: after
the full stack starts, offer to `docker compose stop` whichever of the six
non-core services aren't wanted. Images are already pulled either way, so
anything stopped comes back later with a plain `docker compose up -d
<name>` — no reinstall needed.
Verified the choice-parsing loop in isolation: "1 4 9 3" correctly warns
on the invalid "9" and resolves to gitea/invokeai/kiwix.
Confirmed live: a bare hostname in DOMAIN= (typing "vault.example.com"
instead of "https://vault.example.com" at the install prompt — easy to do
despite the example text showing the scheme) crash-loops the container
with no clear startup error, and re-running the installer doesn't fix an
already-written .env since "update" mode deliberately never touches it.
Two changes, mirroring how the existing SMTP half-state bug is already
handled in this file:
- Normalize VW_DOMAIN at prompt time — missing scheme gets https://
prefixed automatically instead of writing it verbatim.
- New _vaultwarden_fix_domain_scheme() self-heal, called at the same two
sites as _vaultwarden_fix_smtp_halfstate() (the "update" path and the
fresh-install "start now" path), so a box that already has a scheme-less
DOMAIN self-heals on its next start instead of staying stuck.
Verified the self-heal function in isolation: vault.mydomain.com ->
https://vault.mydomain.com.
local-ai-setup.sh runs as whoever invoked this wrapper — root, since
setup.sh itself runs under sudo — so every file it generates
(docker-compose.yml, .env, requirements.txt, server.py, mcp_server.py,
pull-models.sh, start/stop/status.sh) came out root-owned. Nothing handed
that back to ACTUAL_USER unconditionally: the only existing
ensure_docker_dir_ownership call was inside the cloud-provider wiring
block, so it silently never ran at all for anyone who skipped cloud
providers.
Confirmed live: this repo's own "Skipped. Run later: cd $AS_DIR && bash
local-ai-setup.sh" message tells the user to re-run it directly later as
themselves (no sudo) — which then fails with "Permission denied" on any
file root created during the original sudo run, e.g. requirements.txt.
Same root cause class as a stray root-owned .git/FETCH_HEAD blocking a
plain `git pull` — a root-run leaving files a later unprivileged run can't
touch.
Fix: call ensure_docker_dir_ownership "$AS_DIR" unconditionally right
after the installer-run block, not only on the cloud-provider path.
Confirmed live: `docker compose pull` failed with "yaml: line 44, column
29: mapping values are not allowed in this context" during the "Starting
Stack" phase of local-ai-setup.sh. Root cause: two healthcheck blocks
(ollama, chromadb) crammed interval/timeout/retries onto one
semicolon-separated line —
interval: 30s; timeout: 10s; retries: 5
— which isn't valid YAML; a scalar value can't contain a second `key:`
token like that unless quoted. Split each into three separate properly
indented keys, matching how every other multi-key block in this same file
is written.
Verified by generating the actual docker-compose.yml via the real heredoc
(same one docker-stack.md's variables would produce) and parsing the
result with PyYAML — line 44 is exactly the fixed `interval: 30s` line,
and the full file now parses as valid YAML.
Pre-existing bug in the vendored source, unrelated to this session's
earlier ai-stack.sh/local-ai-setup.sh changes (those only touched the
pull-models.sh heredoc and the cloud-provider prompt, both well before
this point in the install) — first surfaced now because this is the first
run in this session to actually reach the "Starting Stack" step rather
than stopping earlier.
Blank already meant skip, but user feedback wanted a keystroke that says
so explicitly rather than just leaving the input empty. Added "0) Skip —
stay fully local" to the menu, updated the prompt to mention it, and
handled "0" as a silent no-op in the choice loop (previously it would
have fallen through to the "Ignoring unknown choice" warning).
The instruction was only in explanatory text a few lines above the actual
prompt (prompt_text "Cloud providers to add []:") — easy to miss once
that's scrolled past, especially since the bracketed default shows empty
but doesn't say what empty means. User feedback: the screen itself should
say it, not just text above it. Now reads "Cloud providers to add (blank =
skip, stay fully local):".
None of local-ai-setup.sh's tier-selected models (CHAT_MODEL/CODE_MODEL/
EMBED_MODEL) can read an image — there was no way to get vision support out
of this stack at all before now. Added a numbered pick-list to the
generated pull-models.sh, right after the existing DeepSeek-R1 optional
pull, matching that same read -rp pattern:
1) moondream ~1.7 GB by Moondream AI — tiny, built for
CPU-only or weak/old-GPU hardware
2) llava:7b ~4.7 GB general-purpose vision
3) qwen2.5vl:7b ~6 GB stronger accuracy, more RAM/VRAM
4) llama3.2-vision:11b ~7.9 GB heaviest of the four
moondream is the recommended default — sized for exactly the "6 vCPU, 8GB
RAM, no GPU" case this was asked for, unlike the other three which assume
real GPU/RAM headroom.
Verified by actually running the heredoc that generates pull-models.sh
(with EMBED_MODEL/CHAT_MODEL/CODE_MODEL stood in) and syntax-checking the
resulting output script, not just the source — the outer heredoc is
unquoted so $-escaping mistakes wouldn't show up as a bash -n failure on
local-ai-setup.sh itself, only on what it generates.
services/ai-stack.md gets a matching "Vision models" section (sizes, the
manual pull command, and how to point an app's OPENAI_MODEL at one).
laptop_full_setup.sh's separate, non-interactive pull-models.sh generator
is untouched — it's not invoked anywhere in this repo's own install flow
(only local-ai-setup.sh is, from install_ai-stack()), so it's out of
scope here.
Confirmed live: install_mealie() pre-computes BASE_URL as
recipes<suffix>.$SITE_DOMAIN before ever asking about Caddy, then
configure_caddy_for_service() separately prompts for a domain — which the
user can freely override (e.g. typing mealie.mydomain.com instead of
accepting the recipes.mydomain.com default). Nothing fed that choice back
into BASE_URL, so it stayed stale. Since BASE_URL is exactly what
_mealie_offer_authelia_oidc() registers as the OIDC redirect URI, this
produced Authelia's "redirect_uri does not match any of the OAuth 2.0
Client's pre-registered redirect_uris" — Caddy and DNS were both correctly
pointed at the new domain, but the client Authelia had on file still said
the old one.
Added CADDY_SERVICE_DOMAIN as a new configure_caddy_for_service() out-param
(lib/common.sh) — the same out-param convention as the existing
CADDY_SERVICE_CONFIGURED/CADDY_SERVICE_MODE, set right after the domain
prompt is accepted. install_mealie() now reconciles BASE_URL against it
immediately after the Caddy call, before the Authelia OIDC step reads
BASE_URL back out of .env. ActualBudget's equivalent OIDC offer asks for
its own domain fresh each time rather than reading a pre-computed BASE_URL,
so it isn't affected by this class of bug and needs no equivalent fix.
Verified the reconciliation logic in isolation against a synthetic .env.
Root cause of the recurring Mealie OIDC "unexpected character '/' in
variable name" failure, confirmed against the user's actual
configuration.yml byte content: this repo's own scripts write
authelia_url/domain unquoted, but YAML makes quoting optional, and a
hand-edited config can add single or double quotes around the value
(here: authelia_url: 'https://authelia.example.com.'). awk's
`print $2`/`print $3` is a naive whitespace-split token grab that doesn't
know about YAML quoting, so it captured the value WITH the literal quote
characters attached. The generated discovery URL then came out
`'https://authelia.example.com.'/.well-known/openid-configuration` —
Docker Compose's env parser closed the quoted value at that embedded
closing quote and choked on the trailing text as an invalid new token.
The earlier \r-stripping commit was a real but different fix (a
CRLF-tainted line fails to match these anchored awk patterns at all) —
it didn't cause and couldn't have fixed this. Both guards are needed and
now both apply, in both _authelia_provision_oidc_client() (domain and
portal URL) and the same latent bug in _authelia_add_oidc_client()'s
domain parse.
Verified end-to-end: reconstructed the user's exact reported byte
content (od -c dump) in a synthetic configuration.yml, ran the actual
_authelia_ensure_oidc_provider/_authelia_provision_oidc_client/
_mealie_offer_authelia_oidc functions against it (docker calls stubbed),
and confirmed the generated .env line is now a single clean line with no
embedded quotes or split.
The previous commit added OIDC_AUTHELIA_PORTAL_URL parsing but only
tr -d '\r'-sanitized the awk output, not the input. That's insufficient
for a CRLF-tainted file (confirmed live: a configuration.yml line
hand-edited by something that saves Windows line endings) — every
line-anchored awk pattern here fails to match at all against a line like
" cookies:\r", since $ anchors end-of-string and the \r is still part of
it, not just leaves a stray \r in the captured value. Symptom was Mealie's
generated OIDC_CONFIGURATION_URL line getting split mid-string, which
Docker Compose's env parser (bare \r treated as a line break too) reported
as "unexpected character '/' in variable name".
Fixed by piping the file through tr -d '\r' before awk sees it, for both
the domain and portal-URL parses. Verified against a synthetic CRLF config
that reproduces the exact failure — both fields now parse clean.
_authelia_provision_oidc_client() gained a new out-param,
OIDC_AUTHELIA_PORTAL_URL, read back from the instance's own
configuration.yml (session.cookies[].authelia_url) — the actual source of
truth for where the portal lives — instead of every caller separately
assuming "https://auth.$domain".
install_authelia() and add_authelia_domain() both still default new
instances to "auth." as before (unchanged), but that's just a default, not
a guarantee: it's plain text in configuration.yml and gets hand-edited on
some boxes (e.g. a dedicated instance renamed to "authelia." to avoid
colliding with another instance's "auth." on a different machine). Mealie,
ActualBudget, and Gitea's native-OIDC wiring all independently hardcoded
"auth." when building their discovery URL, so a renamed portal silently
produced a discovery URL pointing at a host that doesn't serve Authelia —
surfacing as an opaque 500 during the OIDC token exchange with no useful
client-side error.
Verified the new awk parse against both a default ("auth.") and a renamed
("authelia.") cookies block before trusting it.
edit_authelia_user() previously only ever let you select one user, act on
them, and then returned all the way out of install_authelia() (which calls
it with an immediate `return 0`) — deleting several users meant re-running
`sudo ./setup.sh authelia` and re-navigating to option 3 from scratch for
every single one.
Restructured: the per-user action menu (edit/reset-password/2FA/admin/
service-access/delete) is now _authelia_manage_one_user(), and
edit_authelia_user() drives it in a loop — numbered multi-select up front
("2 4" deletes/edits both), then "Manage more users?" to go again with a
freshly re-read user list instead of exiting. Guards against acting on a
user who was already deleted earlier in the same batch.
Verified against a synthetic users.yml: selecting two users by number and
deleting both in one pass removes exactly those two, leaves the others
untouched.