Compare commits

..
97 Commits
Author SHA1 Message Date
Outis b427127200 Merge pull request #397 from outis1one/claude/frigate-authelia-openid-0l1htj
Fix Homebox OIDC invalid_scope error by allowing per-client extra scopes
2026-08-27 08:46:50 -04:00
Claude 6f8a703004 Fix Homebox OIDC invalid_scope error by allowing per-client extra scopes
_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.
2026-08-27 12:39:22 +00:00
Outis 09f46f96c1 Merge pull request #396 from outis1one/claude/frigate-authelia-openid-0l1htj
Claude/frigate authelia openid 0l1htj
2026-08-27 08:17:01 -04:00
Outis dad93dd646 Merge pull request #395 from outis1one/claude/mattermost-android-notifications-d02w94
Claude/mattermost android notifications d02w94
2026-08-27 08:16:34 -04:00
Claude fc9733f937 Wire Authelia SSO into Homebox
_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.
2026-08-27 12:14:25 +00:00
Claude bec9228c55 Back up existing files before every service overwrites them
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).
2026-08-26 17:26:17 +00:00
Claude 31ba6678d7 Add FQDN-change-specific causes to the push-notification troubleshooting doc
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
2026-08-26 00:08:18 +00:00
Claude fce2e7caf1 Document Android push-notification troubleshooting for Mattermost
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
2026-08-25 22:56:58 +00:00
Outis 74b5a0dc7a Merge pull request #394 from outis1one/claude/frigate-authelia-openid-0l1htj
Claude/frigate authelia openid 0l1htj
2026-08-25 13:27:36 -04:00
Claude 493ee30916 Add bulk user-to-group assignment and show access privileges in listings
_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.
2026-08-25 15:52:13 +00:00
Claude 1e625b9955 Relabel "native" access as "internal" throughout Authelia's menus
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.
2026-08-25 15:42:23 +00:00
Claude e9286c8360 Fix wrong Beszel OAuth2 navigation instructions
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.
2026-08-25 13:25:03 +00:00
Claude 7209a32d43 Split "disable local login" from the initial SSO setup step
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.
2026-08-25 13:18:15 +00:00
Outis 294e935ffd Merge pull request #393 from outis1one/claude/frigate-authelia-openid-0l1htj
Claude/frigate authelia openid 0l1htj
2026-08-25 06:55:30 -04:00
Claude 49b965bd1d Add group-first membership management (menu option 16)
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.
2026-08-24 16:32:34 +00:00
Claude 879cb24d3b Wire Authelia SSO into Immich, Audiobookshelf, and Beszel
_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).
2026-08-24 16:08:33 +00:00
Outis 8e7ffc5185 Merge pull request #392 from outis1one/claude/frigate-authelia-openid-0l1htj
Correct stale Mealie vision-import guidance in ai-stack.md
2026-08-24 12:02:25 -04:00
Claude 8745f5ad01 Correct stale Mealie vision-import guidance in ai-stack.md
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).
2026-08-24 15:40:38 +00:00
Outis 2d2e6aae88 Merge pull request #391 from outis1one/claude/frigate-authelia-openid-0l1htj
Add group/site summary view; let Mealie fully hand off login to Authelia
2026-08-24 11:30:17 -04:00
Claude 8808de0dbb Add group/site summary view; let Mealie fully hand off login to Authelia
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.
2026-08-24 12:58:44 +00:00
Outis 5747eebf86 Merge pull request #390 from outis1one/claude/frigate-authelia-openid-0l1htj
Claude/frigate authelia openid 0l1htj
2026-08-24 08:36:08 -04:00
Claude 8aea505541 Generalize site scoping into reusable, named "outside access" groups
_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.
2026-08-24 04:28:32 +00:00
Claude 753a8fdd43 Give admins guaranteed access to every Authelia-protected site, old and new
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
2026-08-24 04:17:35 +00:00
Outis ee7d40b0ce Merge pull request #389 from outis1one/claude/frigate-authelia-openid-0l1htj
Claude/frigate authelia openid 0l1htj
2026-08-23 21:42:17 -04:00
Claude 9a7989b31d Add Authelia menu option to export/import user data
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.
2026-08-24 00:54:14 +00:00
Claude bd5aa223fb Prevent a doubled portal domain when the full domain is typed by mistake
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
2026-08-24 00:15:33 +00:00
Outis 6995afdc66 Merge pull request #388 from outis1one/claude/frigate-authelia-openid-0l1htj
Make every authelia.sh menu numbered with 0 = exit, and fix leftover …
2026-08-23 20:08:36 -04:00
Claude b57c266b84 Make every authelia.sh menu numbered with 0 = exit, and fix leftover "auth." prose
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
2026-08-24 00:04:31 +00:00
Outis 65a8de0ab9 Merge pull request #387 from outis1one/claude/frigate-authelia-openid-0l1htj
Self-install a PATH wrapper so setup.sh works from any directory
2026-08-23 19:50:28 -04:00
Claude a5085cc65b Self-install a PATH wrapper so setup.sh works from any directory
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
2026-08-23 23:45:12 +00:00
Outis ad4758e331 Merge pull request #386 from outis1one/claude/frigate-authelia-openid-0l1htj
Let OIDC app registration pick a domain from Caddy too, not just type it
2026-08-23 19:36:06 -04:00
Claude 8bd0c0f66d Let OIDC app registration pick a domain from Caddy too, not just type it
_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
2026-08-23 23:35:21 +00:00
Outis 23592a43bf Merge pull request #385 from outis1one/claude/frigate-authelia-openid-0l1htj
Claude/frigate authelia openid 0l1htj
2026-08-23 19:30:26 -04:00
Claude b2b83948d8 Add site un-protect and OIDC client removal; fix a duplicate-ID dead end
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
2026-08-23 19:41:47 +00:00
Claude 098bb833cf Add a generic "protect a site with Authelia" menu action
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
2026-08-22 20:55:14 +00:00
Claude ae77992869 Wire the Security Dashboard into Authelia's per-user access scoping
_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
2026-08-22 20:44:19 +00:00
Outis 845e06e079 Merge pull request #384 from outis1one/claude/frigate-authelia-openid-0l1htj
Make the Authelia login portal's subdomain configurable, not hardcoded
2026-08-22 13:06:00 -04:00
Claude cd25903865 Make the Authelia login portal's subdomain configurable, not hardcoded
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
2026-08-22 17:04:37 +00:00
Outis d3553ee168 Merge pull request #383 from outis1one/claude/frigate-authelia-openid-0l1htj
Serve a keep-alive page instead of Asterisk's own web admin publicly
2026-08-22 12:42:21 -04:00
Claude 5fbc1f2ec5 Serve a keep-alive page instead of Asterisk's own web admin publicly
_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
2026-08-22 13:02:29 +00:00
Outis 758adf744e Merge pull request #382 from outis1one/claude/frigate-authelia-openid-0l1htj
Add "remove a protected domain" to Authelia's existing-install menu
2026-08-22 00:47:22 -04:00
Outis 560c33f737 Merge pull request #381 from outis1one/claude/nvidia-gpu-flux-2-c7y9fg
docs(ai-stack): give exact claude mcp add commands for gitea-mcp
2026-08-22 00:46:53 -04:00
Claude b0c9d73030 feat(claude-cli): add dual-account setup with model/effort defaults
New extras service: installs the Claude Code CLI, creates separate
work/personal CLAUDE_CONFIG_DIR accounts behind claude-work/claude-personal
aliases, and applies settings.json defaults to both (model=claude-sonnet-5,
effortLevel=medium, ENABLE_PROMPT_CACHING_1H=1). A shared ~/.claude-shared/
CLAUDE.md holds the durable personal conventions (modular/reuse code,
numbered CLI menus with 0=exit, Playwright verification for UI changes)
and both accounts pull it in via @import so there's one copy to maintain.

Login itself (browser OAuth) can't be scripted - this only prepares the
directories/aliases/config; each account still needs one interactive
login. Tested fresh-install, rerun/cancel detection, and the jq settings
merge (preserves hand-added keys) in a scratch HOME.
2026-08-22 04:46:43 +00:00
Claude 3e9f9dbfed Add "remove a protected domain" to Authelia's existing-install menu
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
2026-08-22 04:45:45 +00:00
Claude b7411b236e docs(ai-stack): give exact claude mcp add commands for gitea-mcp
Replace the vague "add it with claude mcp add" with real commands for
both stdio (single local CLI) and HTTP (shared server) transport,
verified against gitea/gitea-mcp's actual distribution (binary/Docker/
go run, -t stdio|http flags, GITEA_HOST/GITEA_ACCESS_TOKEN env vars).
2026-08-22 04:29:14 +00:00
Outis f52713b67d Merge pull request #380 from outis1one/claude/nvidia-gpu-flux-2-c7y9fg
Claude/nvidia gpu flux 2 c7y9fg
2026-08-22 00:25:15 -04:00
Outis 3614342807 Merge pull request #379 from outis1one/claude/frigate-authelia-openid-0l1htj
Let Security Dashboard take over Asterisk's public domain
2026-08-22 00:24:47 -04:00
Claude cfee4b292b docs(ai-stack): add local-GPU + Claude Code hybrid workflow section
Covers the CLAUDE.md tiering (user/project/local/one-off task) for
splitting instructions between the local coder model and Claude Code,
plus how Claude Code reads from self-hosted Gitea (plain git vs. the
official gitea/gitea-mcp server for PR/issue-level integration).
2026-08-22 04:22:51 +00:00
Claude 92ebbdf9ef Let Security Dashboard take over Asterisk's public domain
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
2026-08-22 04:15:16 +00:00
Claude c703b1c4b4 docs(ai-stack): disambiguate Tesla brand name from Tesla microarchitecture
"Tesla" was NVIDIA's datacenter-card brand (retired after Volta), spanning
four unrelated architecture generations (Kepler/Maxwell/Pascal/Volta) - a
different, much older thing than the Tesla microarchitecture itself, which
actually predates Fermi/Kepler/Maxwell/Pascal/Volta. Clarify to avoid
reading the power-connector note as claiming Tesla == Volta.
2026-08-22 03:55:27 +00:00
Claude 7f23860078 docs(ai-stack): name the R580 driver branch for Volta/Pascal cards
Confirmed via NVIDIA's own release notes: R580 is the last branch
adding Volta/Pascal support (through ~June 2028). Also flag the
proprietary-vs-open kernel module split, since -open only covers
Turing and newer and autoinstall could pick it on a newer distro.
2026-08-22 03:52:39 +00:00
Claude 503945e7c3 docs(ai-stack): correct CUDA deprecation status, add Tesla power/cooling note
CUDA 13 already dropped Pascal/Volta (not a future warning anymore) -
call out pinning the driver branch instead. Also flag the 8-pin
CPU/EPS12V power connector and passive cooling on Tesla PCIe cards,
since it trips up anyone repurposing one in a tower case.
2026-08-22 03:46:06 +00:00
Claude f5a23b06ac docs(ai-stack): note MoE models as a coding option for Pascal/Volta
MoE VRAM tracks total params but compute tracks active params, so a
model like qwen3-coder:30b-a3b (30B total, ~3.3B active) runs light
enough that old GPUs' weak/missing tensor cores barely matter.
2026-08-22 03:41:25 +00:00
Outis a595e45294 Merge pull request #378 from outis1one/claude/nvidia-gpu-flux-2-c7y9fg
docs(ai-stack): add server GPU generation capability table
2026-08-21 23:36:00 -04:00
Outis 2b69cfac1d Merge pull request #377 from outis1one/claude/frigate-authelia-openid-0l1htj
Claude/frigate authelia openid 0l1htj
2026-08-21 23:22:07 -04:00
Claude e54c7827de docs(ai-stack): add server GPU generation capability table
Reference table for Blackwell/Hopper/Ampere/Volta/Pascal/Maxwell covering
Flux 2, Flux.1/SDXL, chat, and coding model capability per generation.
2026-08-22 03:22:02 +00:00
Claude 90da2f5a91 Rename Asterisk container from easy-asterisk to asterisk
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
2026-08-22 03:20:36 +00:00
Claude 47c04ebeb3 Fix keep_alive_interval placement: [global], not [transport-tls]
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
2026-08-22 03:10:37 +00:00
Outis 953744b64c Merge pull request #376 from outis1one/claude/frigate-authelia-openid-0l1htj
Claude/frigate authelia openid 0l1htj
2026-08-21 21:02:38 -04:00
Claude 4eda82acce asterisk.sh: don't flag chained services as unwired when Caddy is remote
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
2026-08-21 22:54:02 +00:00
Claude d1a3c4b0be asterisk.sh: proactive stack health check in update mode
"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
2026-08-21 22:45:36 +00:00
Outis 85f3a89ef5 Merge pull request #375 from outis1one/claude/frigate-authelia-openid-0l1htj
Claude/frigate authelia openid 0l1htj
2026-08-21 17:55:27 -04:00
Claude b7691e6c1b asterisk.sh: offer to fix Caddy when DOMAIN_NAME is set but unwired, in update mode
"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
2026-08-21 21:55:15 +00:00
Claude da59b65ceb Wire up MWI so voicemail notifications actually reach the phone
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
2026-08-21 21:37:52 +00:00
Claude b0d51f0344 Fix sms-inbound.sh persisting an unusable "<your-domain>" webhook URL
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
2026-08-21 21:13:16 +00:00
Claude be60f475c3 Add zero-click Authelia login to Gitea and Uptime Kuma
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
2026-08-21 21:07:02 +00:00
Claude cd33b7ce71 Add Authelia SSO to Frigate — disables its own login, not just a gate in front of it
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
2026-08-21 19:56:27 +00:00
Outis afc59613fc Merge pull request #374 from outis1one/claude/gitea-standalone-setup-oxoi2e
Claude/gitea standalone setup oxoi2e
2026-08-21 11:29:24 -04:00
Claude 3b4c238e1d ai-stack: add RAG/MCP stack to the optional-services picker, with dependency cascade
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.
2026-08-21 15:23:18 +00:00
Claude 596bd4a9d5 ai-stack: offer to stop optional services after install instead of always running everything
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.
2026-08-21 15:11:33 +00:00
Outis 88d48e0e74 Merge pull request #373 from outis1one/claude/gitea-standalone-setup-oxoi2e
vaultwarden: require/enforce an http(s):// scheme on DOMAIN
2026-08-21 10:00:37 -04:00
Claude 1018dd8c9e vaultwarden: require/enforce an http(s):// scheme on DOMAIN
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.
2026-08-21 05:19:25 +00:00
Outis ed9251580a Merge pull request #372 from outis1one/claude/gitea-standalone-setup-oxoi2e
ai-stack: chown the stack dir back to ACTUAL_USER after every install…
2026-08-21 00:09:23 -04:00
Claude 1ff12d6643 ai-stack: chown the stack dir back to ACTUAL_USER after every installer run
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.
2026-08-21 04:05:27 +00:00
Outis 175e1679bf Merge pull request #371 from outis1one/claude/gitea-standalone-setup-oxoi2e
ai-stack: fix invalid YAML in generated docker-compose.yml healthchecks
2026-08-21 00:02:47 -04:00
Claude 9bf49b5daa ai-stack: fix invalid YAML in generated docker-compose.yml healthchecks
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.
2026-08-21 04:01:48 +00:00
Outis 45f5c6c1e1 Merge pull request #370 from outis1one/claude/gitea-standalone-setup-oxoi2e
Claude/gitea standalone setup oxoi2e
2026-08-20 23:52:25 -04:00
Claude b4eccbd02d ai-stack: accept 0 as an explicit "skip cloud providers" choice
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).
2026-08-21 03:51:41 +00:00
Claude 4399e8db71 ai-stack: state "skip = stay fully local" on the prompt line itself
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):".
2026-08-21 03:50:12 +00:00
Outis 22d213025d Merge pull request #369 from outis1one/claude/gitea-standalone-setup-oxoi2e
ai-stack: offer an optional vision-capable Ollama model, including mo…
2026-08-20 23:27:17 -04:00
Claude ac76ef5181 ai-stack: offer an optional vision-capable Ollama model, including moondream
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.
2026-08-21 03:09:07 +00:00
Outis 0a798d5ec9 Merge pull request #368 from outis1one/claude/gitea-standalone-setup-oxoi2e
mealie: reconcile BASE_URL with the domain actually chosen at the Cad…
2026-08-20 21:19:41 -04:00
Claude ebe8ea3245 mealie: reconcile BASE_URL with the domain actually chosen at the Caddy prompt
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.
2026-08-20 22:03:54 +00:00
Outis 7a8b8b001b Merge pull request #367 from outis1one/claude/gitea-standalone-setup-oxoi2e
authelia: strip YAML quoting from parsed domain/portal-URL values
2026-08-20 17:11:08 -04:00
Claude 7aef571b27 authelia: strip YAML quoting from parsed domain/portal-URL values
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.
2026-08-20 21:10:22 +00:00
Outis 9ec4ee7963 Merge pull request #366 from outis1one/claude/gitea-standalone-setup-oxoi2e
authelia: strip CR before parsing configuration.yml, not after
2026-08-20 16:26:20 -04:00
Claude 86331b541d authelia: strip CR before parsing configuration.yml, not after
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.
2026-08-20 20:22:42 +00:00
Outis 96741c4f32 Merge pull request #365 from outis1one/claude/gitea-standalone-setup-oxoi2e
authelia: stop hardcoding "auth." as the portal subdomain for OIDC di…
2026-08-20 16:18:50 -04:00
Claude fc3b85f970 authelia: stop hardcoding "auth." as the portal subdomain for OIDC discovery
_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.
2026-08-20 20:17:58 +00:00
Outis 9ecf37c56f Merge pull request #364 from outis1one/claude/gitea-standalone-setup-oxoi2e
authelia: let user-management menu pick and act on multiple users per…
2026-08-20 15:22:41 -04:00
Claude 4b0f453952 authelia: let user-management menu pick and act on multiple users per run
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.
2026-08-20 19:21:45 +00:00
Outis c3133711cc Merge pull request #363 from outis1one/claude/gitea-standalone-setup-oxoi2e
authelia: fix uppercase-username stripping, add delete/service-access…
2026-08-20 15:18:12 -04:00
Claude 31cbe435bc authelia: fix uppercase-username stripping, add delete/service-access to user menu
tr -cs 'a-z0-9_-' '-' only allowed lowercase letters, so any uppercase
leading character (e.g. "Bob") got converted to a dash and then stripped
by the paired leading-dash sed, silently truncating the username. Widened
to a-zA-Z0-9_- in both add_authelia_user() and _authelia_scope_access().

Also extends the "Manage an existing user" menu (still numbered-selection
throughout) with:
- option 6: toggle a user's membership in any existing "<service>-only"
  scoped-access group, picked by number, via two new helpers
  (_authelia_list_scoped_groups, and re-resolving the user's line range
  before each toggle since a prior toggle in the same pass shifts it)
- option 7: delete a user outright (_authelia_delete_user_block), with confirmation

Verified against a synthetic users.yml (add/remove toggling across
multiple groups, block deletion, uppercase-username round-trip) before
touching the live file.
2026-08-20 19:02:37 +00:00
Outis 040b006457 Merge pull request #362 from outis1one/claude/gitea-standalone-setup-oxoi2e
authelia/mealie/actualbudget/gitea: no more silent no-op reruns
2026-08-20 14:51:13 -04:00
Claude accfd7a5bb authelia/mealie/actualbudget/gitea: no more silent no-op reruns
Confirmed live: re-running ActualBudget's Update path produced zero
output for the Authelia SSO step — no prompt, no message, straight back
to the shell. Root cause: the idempotency guards in
_actualbudget_offer_authelia_oidc / _mealie_offer_authelia_oidc /
_gitea_offer_actions_runner were plain `grep -q ... && return 0` — silent
by construction. Indistinguishable from the step not running at all,
which is exactly what it looked like.

ActualBudget and Mealie's OIDC offers now explain what they found and
ask whether to reconfigure (registers a fresh Authelia client + secret,
clearing the old env vars first) instead of silently bailing. Gitea's
Actions-runner offer explains what it found and how to check its status
(reconfiguring that one means editing a docker-compose service block,
not just a couple of env vars, so it just informs rather than offering
to redo it).

Also: _authelia_scope_access now shows existing Authelia usernames as a
numbered list before asking who should have access — picking by number
works alongside typing new names directly (mix freely, e.g. "1 3
newperson"), rather than requiring exact usernames typed from memory
with no reference and no protection against a typo silently creating a
duplicate account.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEQNc4NfBST1m9NtCZVYa8
2026-08-20 18:49:43 +00:00
Outis 0d605cc8c7 Merge pull request #361 from outis1one/claude/gitea-standalone-setup-oxoi2e
authelia: show existing users before asking who to scope access to
2026-08-20 14:44:43 -04:00
Claude 15cef3ab1d authelia: show existing users before asking who to scope access to
_authelia_scope_access asked for usernames to grant access to a service
without ever showing who already exists — confirmed live, the prompt
just showed a blank "Usernames:" line with nothing to reference. A typo
against an existing name doesn't fail or warn, it silently creates a new,
separate account instead of matching the intended one.

Now lists existing Authelia users (reusing _authelia_list_usernames,
already used elsewhere in this file) right before the prompt, and warns
about the typo/duplicate-account risk explicitly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEQNc4NfBST1m9NtCZVYa8
2026-08-20 18:43:13 +00:00
72 changed files with 4572 additions and 332 deletions
+163 -27
View File
@@ -44,11 +44,26 @@ public-FQDN-only flow, hand-built Caddy site block, remote Authelia, Cloud
Firewall — behind that one answer. Two lessons worth reusing: Firewall — behind that one answer. Two lessons worth reusing:
- **Don't rename a live install's directory or containers.** New installs - **Don't rename a live install's directory or containers.** New installs
land in `~/docker/asterisk` with `easy-asterisk`; a pre-merge droplet keeps land in `~/docker/asterisk` with a container named `asterisk`; a pre-merge
`~/docker/asterisk-digital-ocean` and `easy-asterisk-do`, because its droplet keeps `~/docker/asterisk-digital-ocean` and `easy-asterisk-do`,
Caddyfile block, UFW rules, Cloud Firewall, CrowdSec acquisition and PSTN because its Caddyfile block, UFW rules, Cloud Firewall, CrowdSec
trunk all name those exact paths. `_asterisk_resolve_layout()` picks acquisition and PSTN trunk all name those exact paths.
whichever exists, and every sibling service probes both. `_asterisk_resolve_layout()` picks whichever directory exists, and every
sibling service probes both. The plain container name was itself renamed
once already — from `easy-asterisk` (this repo's original choice, reusing
the vendor CLI tool's own name, `/usr/local/bin/easy-asterisk` inside the
container — unrelated, never renamed) to plain `asterisk`, matching every
other service's own `container_name == service name` convention. The same
"don't rename under a running deployment" rule applied: every resolver
(`_asterisk_resolve_layout()` and the duplicated copies in
`security-dashboard.sh`, `sms-inbound.sh`, `pstn-trunk.sh`,
`tools/pstn-test-check.sh`) reads the container name out of the box's own
`docker-compose.yml` instead of assuming it, so an existing `easy-asterisk`
install keeps working unchanged. Migrating one to the new name is a
deliberate, one-time action on that box (edit `docker-compose.yml`'s
`container_name:` for both Asterisk and its coturn sidecar, `docker compose
down` + `up -d`) — once done, every sibling service re-reads it from that
same file and follows automatically.
- **Check whether a "flavor-specific" behavior was actually flavor-specific.** - **Check whether a "flavor-specific" behavior was actually flavor-specific.**
The Asterisk security-logging patch and the `logs/full` logrotate config The Asterisk security-logging patch and the `logs/full` logrotate config
were droplet-only purely because that's where they got written first — the were droplet-only purely because that's where they got written first — the
@@ -191,13 +206,25 @@ pip_user_install PACKAGE... # pip3 --user with --break-system-packages o
### Caddy reverse proxy ### Caddy reverse proxy
```bash ```bash
configure_caddy_for_service "Display Name" "PORT" "default-subdomain" ["extra-block"] configure_caddy_for_service "Display Name" "PORT" "default-subdomain" ["extra-block"] ["reverse_proxy-extra"]
``` ```
Prompts the user for a domain, appends a site block to the Caddyfile, and Prompts the user for a domain, appends a site block to the Caddyfile, and
reloads Caddy. No-ops silently if Caddy isn't installed. The fourth argument reloads Caddy. No-ops silently if Caddy isn't installed. The fourth argument
is an optional string inserted verbatim inside the Caddy site block (use it is an optional string inserted verbatim inside the Caddy site block, before
for `import authelia` or custom matchers). `reverse_proxy` (use it for `import authelia` or custom matchers). The fifth
argument is a different thing — an optional string inserted **inside** the
`reverse_proxy` block itself, as sub-directives (e.g.
`" header_up X-Proxy-Secret abc123"`), for a backend that needs a
header only `reverse_proxy`'s own `header_up` can set — the fourth
argument's block runs *before* `reverse_proxy` and can't reach into it.
`services/frigate.sh` is the reference caller: Frigate's `proxy` auth mode
trusts `Remote-User`/`Remote-Groups` headers from Authelia's forward_auth,
but only if a matching `X-Proxy-Secret` header is also present — otherwise
those headers could be spoofed by a request that reaches Frigate's
published host port directly, bypassing Caddy/Authelia entirely. Omit the
fifth argument and the generated `reverse_proxy` line is the same bare form
as before — every other caller is unaffected.
The function places that block **before** `reverse_proxy` in the generated The function places that block **before** `reverse_proxy` in the generated
site block — don't reorder this. `forward_auth` (what `import authelia` site block — don't reorder this. `forward_auth` (what `import authelia`
@@ -245,14 +272,19 @@ forward_auth https://auth.example.com {
This only affects the remote-Authelia path — same-machine `authelia:9091` This only affects the remote-Authelia path — same-machine `authelia:9091`
snippets (`services/authelia.sh`) are a single hop and don't need it. snippets (`services/authelia.sh`) are a single hop and don't need it.
Sets two out-params (not `local` — read them after the call returns) so the Sets three out-params (not `local` — read them after the call returns) so
caller can tell whether Caddy actually ended up fronting the service: the caller can tell whether Caddy actually ended up fronting the service:
```bash ```bash
CADDY_SERVICE_CONFIGURED # true/false CADDY_SERVICE_CONFIGURED # true/false
CADDY_SERVICE_MODE # "local" or "remote" (only meaningful if configured) CADDY_SERVICE_MODE # "local" or "remote" (only meaningful if configured)
CADDY_SERVICE_DOMAIN # the domain actually configured (only meaningful if configured)
``` ```
`CADDY_SERVICE_DOMAIN` is what `_authelia_scope_access()` (see below) wants
as its `DOMAIN` argument — read it right after the call instead of
recomputing/guessing the domain a second time.
Use this to skip opening a host firewall port for a service Caddy already Use this to skip opening a host firewall port for a service Caddy already
fronts *locally* (it reaches the service over `host.docker.internal`, not fronts *locally* (it reaches the service over `host.docker.internal`, not
the network) — but still open it when `CADDY_SERVICE_MODE` is `"remote"`, the network) — but still open it when `CADDY_SERVICE_MODE` is `"remote"`,
@@ -390,22 +422,49 @@ existing login page. Reuse `_authelia_provision_oidc_client()` (guarded by
instead of duplicating Authelia's client-secret-generation/config-patching instead of duplicating Authelia's client-secret-generation/config-patching
logic again. logic again.
**Scoping a domain to specific users instead of every Authelia user.** **Internal vs. outside access — named, reusable groups, not one group per
By default, any domain with an `access_control` rule at all is reachable by service.** By default, any domain with an `access_control` rule at all is
every Authelia user (the existing catch-all `*.${AUTHELIA_DOMAIN}` rule). "internal": reachable by every Authelia user (the existing catch-all
`services/authelia.sh`'s `_authelia_scope_access(SERVICE_ID, DOMAIN)` is a `*.${AUTHELIA_DOMAIN}` rule) — admins included automatically, since the
generic, reusable opt-in on top of that — call it right after *any* service admin-bypass rule (below) always outranks it anyway. `services/authelia.sh`'s
finishes being protected by Authelia, forward_auth gate or native OIDC `_authelia_scope_access(SERVICE_ID, DOMAIN)` is the generic, reusable opt-in
alike (it only cares about the domain, not the gating mechanism; see on top of that for "outside access" — call it right after *any* service
`_gitea_offer_authelia_sso()` for the reference caller). Asks whether finishes being protected by Authelia, forward_auth gate or native OIDC alike
access should stay universal or be scoped to specific usernames; if scoped, (it only cares about the domain, not the gating mechanism; see
creates a dedicated `<service_id>-only` group, adds every listed username `_gitea_offer_authelia_sso()` for the reference caller). Asks "Internal
to it (creating accounts on the fly via (default) or Outside access", and if outside access, lets the admin pick an
*existing* named group (by number, so e.g. "customer1" can be attached to a
second, third, unrelated site later) or type a new one — `service_id` is
only the suggested default name, never forced. Creates the group if new
(adding every listed username to it, creating accounts on the fly via
`_authelia_create_user_noninteractive()` for names that don't exist yet, `_authelia_create_user_noninteractive()` for names that don't exist yet,
printing their temp password), and inserts two rules *above* the general printing their temp password) and inserts two rules *above* the general
catch-all — allow that group on this domain, deny that group on every catch-all but *below* the admin-bypass rule — allow that group on this
other protected domain. Idempotent: reruns against an already-scoped domain, deny that group on every other protected domain. The already-scoped
domain just report the existing group instead of duplicating rules. check is keyed to the (domain, group) pair, not the group name alone, so
reusing a group on a second site correctly adds that site's own rule instead
of a false "already scoped" no-op (a real bug in an earlier version of this
function, since fixed).
Three more menu options round this out: **13** backfills the admin-bypass
rule (below) onto any apex domain missing it; **14** renames a group
everywhere it's referenced (rules + every member); **15** lists every
group's sites and members in one place; **16** is the reverse of the
scoping prompt's own member-picker — pick a group first, then toggle which
users are in it, for adding members without re-touching a site.
**Admins always match first, on every domain — old sites and new.**
`_authelia_ensure_admin_bypass(config_file, domain)` inserts
`- domain: "*.${domain}" / subject: "group:admins" / policy: two_factor` as
literally the first rule under `rules:`, and every insertion point that
adds new rules (`add_authelia_domain`, `_authelia_scope_access`) inserts
*after* this block rather than at the literal top of `rules:`, so a later
scoping action can never accidentally outrank it. Without this, a group's
deny-elsewhere rule (above) would deny an admin who's ever added to that
group on every OTHER domain — this rule exists specifically so that can't
happen. `install_authelia()`/`add_authelia_domain()` bake it in for
anything created from here on; menu option 13 backfills it onto an
instance that predates the feature.
Guard every cross-file call with `declare -F`, same convention as the OIDC Guard every cross-file call with `declare -F`, same convention as the OIDC
helper above — a service can run standalone with authelia.sh never sourced. helper above — a service can run standalone with authelia.sh never sourced.
@@ -434,13 +493,16 @@ right (Portainer, ntfy), not general familiarity with the product:
| Service | Native OIDC? | Notes | | Service | Native OIDC? | Notes |
|---|---|---| |---|---|---|
| `mealie` | Yes — wired up | Pure env vars (`OIDC_AUTH_ENABLED`, `OIDC_CLIENT_ID/SECRET`, `OIDC_CONFIGURATION_URL`), see `_mealie_offer_authelia_oidc()`. Redirect URI is `<BASE_URL>/login`. Needs a `--forwarded-allow-ips` entrypoint override when Caddy-fronted, or the generated redirect URI comes out `http://` even when actually served over `https://` — see the function's own comment. | | `mealie` | Yes — wired up | Pure env vars (`OIDC_AUTH_ENABLED`, `OIDC_CLIENT_ID/SECRET`, `OIDC_CONFIGURATION_URL`), see `_mealie_offer_authelia_oidc()`. Redirect URI is `<BASE_URL>/login`. Needs a `--forwarded-allow-ips` entrypoint override when Caddy-fronted, or the generated redirect URI comes out `http://` even when actually served over `https://` — see the function's own comment. |
| `homebox` | Yes — wired up | Pure env vars (`HBOX_OIDC_ENABLED`, `HBOX_OIDC_ISSUER_URL`, `HBOX_OIDC_CLIENT_ID/SECRET`, `HBOX_OIDC_SCOPE`), see `_homebox_offer_authelia_oidc()`. Confirmed against homebox.software's own OIDC docs and authelia.com's Homebox integration page — needs PKCE (unlike Mealie/ActualBudget). Redirect path is `/api/v1/users/login/oidc/callback`; issuer URL is reportedly sensitive to a trailing slash (a real upstream bug), so it's written from this repo's own portal-URL value as-is, never with one appended. The stock compose template didn't have `env_file: .env` (vars were listed individually in `environment:` instead) — added to the template, and patched onto any pre-existing install's compose file the first time this offer runs, or the written `.env` additions would silently never reach the container. `HBOX_OPTIONS_ALLOW_LOCAL_LOGIN=false`/`HBOX_OIDC_AUTO_REDIRECT=true` are real, documented env vars for fully replacing local login, offered as a separate step gated behind the same "have you tested the button first" confirmation as Mealie/Beszel. Unlike every other native-OIDC integration in this table, `HBOX_OIDC_SCOPE` needs a fourth scope, `groups`, alongside the usual `openid profile email` — Authelia's own Homebox integration page documents this. Confirmed live: requesting it without also granting it broke login outright (`invalid_scope: "The OAuth 2.0 Client is not allowed to request scope 'groups'"`), because Authelia enforces a per-client scopes allowlist independent of what the server supports overall — `_authelia_provision_oidc_client()` used to hardcode `openid`/`profile`/`email` for every caller with no way to add more. Fixed by giving it a 6th positional arg, `EXTRA_SCOPES` (space-separated, inserted right after `REQUIRE_PKCE`), that every other existing caller passes as `""` — Homebox's is the only caller that passes `"groups"`. |
| `actualbudget` | Yes — wired up | Pure env vars (`ACTUAL_OPENID_DISCOVERY_URL`, `ACTUAL_OPENID_CLIENT_ID/SECRET`, `ACTUAL_OPENID_SERVER_HOSTNAME`), see `_actualbudget_offer_authelia_oidc()`. Redirect path `/openid/callback` (matches the existing preset in `_authelia_add_oidc_client()`'s menu). First OIDC login becomes the server owner if none is set yet — Actual's own behavior. | | `actualbudget` | Yes — wired up | Pure env vars (`ACTUAL_OPENID_DISCOVERY_URL`, `ACTUAL_OPENID_CLIENT_ID/SECRET`, `ACTUAL_OPENID_SERVER_HOSTNAME`), see `_actualbudget_offer_authelia_oidc()`. Redirect path `/openid/callback` (matches the existing preset in `_authelia_add_oidc_client()`'s menu). First OIDC login becomes the server owner if none is set yet — Actual's own behavior. |
| `immich` | Yes, not yet wired up | Real OAuth2/OIDC settings under Administration → Settings, backed by a `system-config` API (GET/PUT) — confirmed the API exists, but didn't confirm the exact request payload shape needed to set OAuth fields specifically. Needs one more verification pass against the live OpenAPI spec before automating; don't guess the payload. | | `immich` | Yes wired up | Real OAuth2/OIDC settings under Administration → Settings, backed by `GET`/`PUT /api/system-config` — confirmed the exact JSON field names against Immich's own `config-file.md` and source directly (the `oauth` sub-object: `enabled`/`issuerUrl`/`clientId`/`clientSecret`/`scope`/`buttonText`, etc.), not guessed. See `_immich_offer_authelia_oidc()`. GET/PUT exchange the *whole* config object (no partial-patch endpoint), so it round-trips everything else — storage template, library settings — completely unchanged; the same shape already proven by `import-photos.sh`'s own storage-template step in this file. Needs an admin API key, which doesn't exist until the user creates their account on first web visit — this offer runs from both the fresh-install path (usually a no-op that first time) and the "update" rerun path, which is the realistic way most people finish this. |
| `audiobookshelf` | Yes — wired up (Authelia side only) | Checked against audiobookshelf.org's own OIDC docs: config is UI-only (Settings → Authentication), no env var or config API — so `_audiobookshelf_offer_authelia_oidc()` registers the Authelia client (needs PKCE, confirmed via authelia.com's own integration page for it) and prints the exact individual-endpoint values to paste in, since Audiobookshelf wants those rather than a discovery URL. Three redirect URIs: web callback, mobile-redirect, and the `audiobookshelf://oauth` app-scheme callback. |
| `beszel` | Yes — wired up (Authelia side only) | PocketBase-based; its OAuth2 provider is a PocketBase admin-UI setting (Settings → Auth providers), not an API — checked against beszel.dev directly. `_beszel_offer_authelia_oidc()` registers the Authelia client (also needs PKCE, per authelia.com's Beszel integration page) and prints the paste-in values. Separately offers the real, documented `DISABLE_PASSWORD_AUTH`/`USER_CREATION` env vars to fully replace Beszel's own login — gated behind an explicit warning to register a working account first, since Beszel has no default account and no signup-fallback if that hasn't happened yet. |
| `jellyfin` | Only via a third-party plugin | No official native OIDC. Community plugins exist (`jellyfin-plugin-sso`, `jellyfin-plugin-oidc`) but are web-UI-only — native mobile/desktop Jellyfin clients can't use them. A bigger lift than an env-var toggle (plugin install via Jellyfin's own plugin repo system); hold off until that's worth doing deliberately. | | `jellyfin` | Only via a third-party plugin | No official native OIDC. Community plugins exist (`jellyfin-plugin-sso`, `jellyfin-plugin-oidc`) but are web-UI-only — native mobile/desktop Jellyfin clients can't use them. A bigger lift than an env-var toggle (plugin install via Jellyfin's own plugin repo system); hold off until that's worth doing deliberately. |
| `homeassistant` | Only via a third-party HACS integration | No native core OIDC as of 2026 (open community discussion asking for it, not shipped). `hass-oidc-auth`/`hass-openid` exist as HACS-installed integrations — same "bigger lift" caveat as Jellyfin. | | `homeassistant` | Only via a third-party HACS integration | No native core OIDC as of 2026 (open community discussion asking for it, not shipped). `hass-oidc-auth`/`hass-openid` exist as HACS-installed integrations — same "bigger lift" caveat as Jellyfin. |
| `portainer` | No (CE) | OAuth/OIDC is a **Business Edition** feature — this repo installs `portainer-ce` (confirmed in `services/portainer.sh`), which doesn't have it. CE's documented path is fronting it with `oauth2-proxy`, i.e. no different from the forward_auth pattern any no-built-in-auth service already uses — not "native OIDC" in the sense this section means. | | `portainer` | No (CE) | OAuth/OIDC is a **Business Edition** feature — this repo installs `portainer-ce` (confirmed in `services/portainer.sh`), which doesn't have it. CE's documented path is fronting it with `oauth2-proxy`, i.e. no different from the forward_auth pattern any no-built-in-auth service already uses — not "native OIDC" in the sense this section means. |
| `ntfy` | No | Checked ntfy's own config docs directly — no `auth-oauth2-*` keys exist. Only basic auth + access tokens + ACLs. (Worth a re-check on a future ntfy release if this matters to you — this class of feature does get added to self-hosted tools over time.) | | `ntfy` | No | Checked ntfy's own config docs directly — no `auth-oauth2-*` keys exist. Only basic auth + access tokens + ACLs. (Worth a re-check on a future ntfy release if this matters to you — this class of feature does get added to self-hosted tools over time.) |
| `emby`, `audiobookshelf`, `meshcentral`, `traccar`, `uptimekuma`, `filebrowser`, `wg-easy` | Not individually re-verified | High-confidence no, based on general familiarity with each product rather than a fresh doc check this pass (unlike everything above, which was actually checked and in two cases contradicted assumption). Verify before wiring any of these in, the same way the checked ones were — don't extrapolate from this table's pattern. | `emby`, `meshcentral`, `traccar`, `uptimekuma`, `filebrowser`, `wg-easy` | Not individually re-verified | High-confidence no, based on general familiarity with each product rather than a fresh doc check this pass (unlike everything above, which was actually checked and in two cases contradicted assumption). Verify before wiring any of these in, the same way the checked ones were — don't extrapolate from this table's pattern.
**No built-in auth — should be protected:** **No built-in auth — should be protected:**
`magicmirror`, `wolf-pair`, `js99er`, `drum-rhythm-game`, `iopaint`, `magicmirror`, `wolf-pair`, `js99er`, `drum-rhythm-game`, `iopaint`,
@@ -461,6 +523,80 @@ for Authelia to protect. Removed from this list; if it grows a web UI in
the future, add it back and wire up the same prompt other services here the future, add it back and wire up the same prompt other services here
use. use.
**`frigate` — a third pattern, neither of the two above.** Frigate *does*
have built-in auth (username/password, `admin`/`viewer` roles, on by
default) so it isn't "no built-in auth" — but unlike the has-built-in-auth
list, that auth is designed to be handed off to an upstream proxy instead
of just living alongside it. Frigate has its own `proxy` auth mode built
specifically for Authelia/Authentik/oauth2_proxy/traefik-forward-auth:
given trusted `Remote-User`/`Remote-Groups` headers it can skip its own
login screen entirely (`auth.enabled: False`), rather than showing a
second, independently-expiring login *after* Authelia's. `services/frigate.sh`
wires this up: `import authelia` (fourth arg) plus a
`header_up X-Proxy-Secret <secret>` (fifth arg, see
`configure_caddy_for_service` above) into the reverse_proxy block, with
the matching `proxy.auth_secret`/`header_map`/`default_role: admin` block
written into `config/config.yml` — and only written at all once
`CADDY_SERVICE_CONFIGURED` confirms Caddy actually ended up fronting the
domain, so Frigate's own login is never disabled with nothing else in
front of it. `default_role: admin` (default in this repo's install) means
anyone who passes Authelia gets full access, same as the login it
replaces; use `proxy.role_map`/Authelia groups instead if some users
should be view-only. Reuses the same `FRIGATE_PROXY_AUTH_SECRET` on
reinstall (from `.env` via `ENV_MAP`, the same array `_frigate_parse_existing`
already builds) rather than rotating it and breaking the existing Caddy
pairing.
**`gitea` and `uptimekuma` — two more "disable/bypass built-in login,
Authelia is the only gate" integrations, each with its own trust model.**
Both are opt-in extras layered on top of the has-built-in-auth entries
those services already had; neither replaces the existing behavior for
anyone who doesn't ask for it.
- `gitea`'s `_gitea_offer_reverse_proxy_auth()` is a *second*, stronger
Authelia integration alongside the OIDC "Sign in with Authelia" button
(`_gitea_offer_authelia_sso()`, unchanged): Gitea's own
`ENABLE_REVERSE_PROXY_AUTHENTICATION` mode auto-logs in as whatever
username arrives in a trusted header — no click, no separate Gitea
session with its own expiry. Unlike Frigate, Gitea's own login page
isn't disabled — it stays as a fallback for anyone not arriving through
the trusted path, so there's no "native login off with nothing gating
it" failure mode to guard against here. The trust boundary is
`REVERSE_PROXY_TRUSTED_PROXIES` (an IP range), not a shared secret —
Gitea's own Docker image has shipped this wildcarded before (a real CVE,
GHSA-f75j-4cw6-rmx4: any source IP could set `X-WEBAUTH-USER` and log in
as anyone), so this always computes the range from caddy_net's actual
subnet (`docker network inspect ... --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'`,
the same lookup `ufw_allow_from_caddy_net` uses) and refuses to enable
the feature at all if that can't be determined — never falls back to a
permissive default. `REVERSE_PROXY_AUTHENTICATION_USER`/`_EMAIL` are set
to `Remote-User`/`Remote-Email` to match Authelia's `import authelia`
snippet's own `copy_headers` output directly, rather than renaming
headers in Caddy to match Gitea's own `X-WEBAUTH-USER` default. Gitea
currently reaches Caddy over its published host port
(`host.docker.internal:PORT`), not caddy_net, because it predates this
feature — enabling it rewires Gitea onto caddy_net (like every other
locally-Caddy-fronted service) and re-points Caddy's upstream at
`gitea:3000`, replacing the old site block via
`configure_caddy_for_service`'s own existing "already exists —
overwrite?" prompt. Local Caddy only; a remote Caddy machine's source
address isn't a stable, narrowly-scopeable range the way caddy_net's
bridge subnet is.
- `uptimekuma`'s equivalent is much simpler: Uptime Kuma's `DISABLE_AUTH=true`
env var turns its own login off *completely*, with no IP-range or secret
check left at all — once set, anything that can reach its port is in, no
questions asked. That makes it the one of these three where getting the
ordering wrong is worst: `services/uptimekuma.sh` only ever sets
`DISABLE_AUTH=true` after `configure_caddy_for_service "Uptime Kuma" "uptime-kuma:3001" "uptime" " import authelia"`
confirms `CADDY_SERVICE_CONFIGURED` — the same never-disable-native-auth-
without-a-confirmed-gate rule Frigate follows. Uptime Kuma already joined
caddy_net unconditionally before this (see its own `_CADDY_NET_BLOCK`),
so no networking change was needed here, just the env var and the
Authelia-gated Caddy call happening earlier (before `docker-compose.yml`
is written) instead of the plain unconditional call this file already
had at the end — which now only runs as a fallback when the Authelia
path wasn't used or wasn't completed.
For services without built-in auth, prompt the user before calling For services without built-in auth, prompt the user before calling
`configure_caddy_for_service` and pass `import authelia` as the extra block `configure_caddy_for_service` and pass `import authelia` as the extra block
if Authelia is installed and the user wants SSO protection: if Authelia is installed and the user wants SSO protection:
+2 -1
View File
@@ -190,7 +190,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`.
| `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` | | `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` |
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
| `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` | | `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` |
| `extras` | `kdeconnect`, `silent-send`, `ssh-config`, `ssh-key-import` (import SSH public keys from GitHub/Launchpad, optionally lock down password auth — same step base.sh's required setup runs, re-runnable on its own), `sync-cc` | | `extras` | `kdeconnect`, `silent-send`, `ssh-config`, `ssh-key-import` (import SSH public keys from GitHub/Launchpad, optionally lock down password auth — same step base.sh's required setup runs, re-runnable on its own), `sync-cc`, `claude-cli` (Claude Code CLI — dual-account work/personal setup, model/effort defaults, shared global CLAUDE.md) |
| `backup` | `backup` — complete recovery: entire `~/docker/<service>/` for every service via Kopia (Minecraft: flush+snap, no downtime; others: stop/snap/start for DB consistency), optional offsite mirror (`kopia repository sync-to`), plus `dr_bringup.sh` — unattended restore-everything-and-start for standing up a cold spare box; `borg-backup` — same coverage via Borg (chunk dedup, SSH remote repos, Borgmatic/Vorta compatible); `gaming-backup` — frequent game-save snapshots (Minecraft world data, emulator saves, Steam — no downtime, run hourly) | | `backup` | `backup` — complete recovery: entire `~/docker/<service>/` for every service via Kopia (Minecraft: flush+snap, no downtime; others: stop/snap/start for DB consistency), optional offsite mirror (`kopia repository sync-to`), plus `dr_bringup.sh` — unattended restore-everything-and-start for standing up a cold spare box; `borg-backup` — same coverage via Borg (chunk dedup, SSH remote repos, Borgmatic/Vorta compatible); `gaming-backup` — frequent game-save snapshots (Minecraft world data, emulator saves, Steam — no downtime, run hourly) |
Run `./setup.sh --list` to see descriptions. Run `./setup.sh --list` to see descriptions.
@@ -282,6 +282,7 @@ extras
ssh-config ssh-config
ssh-key-import ssh-key-import
sync-cc sync-cc
claude-cli
backup backup
backup backup
+92 -3
View File
@@ -416,6 +416,42 @@ _remove_caddy_site_block() {
' "$caddy_file" ' "$caddy_file"
} }
# Read-only counterpart to _remove_caddy_site_block: returns (on stdout) the
# domain of the local Caddy site block whose body contains
# "reverse_proxy <upstream>" (same substring-match convention), or nothing
# if there's no local Caddy, no Caddyfile, or no matching block. Never
# modifies the Caddyfile — for services/asterisk.sh's stack health check
# (and any future caller) to answer "is X actually wired into Caddy?"
# without needing to already know the domain, since several services here
# (security-dashboard, sms-inbound) never persist the domain they were
# configured with anywhere — the Caddyfile is the only record of it.
caddy_domain_for_upstream() {
local upstream="$1"
local caddyfile="$DOCKER_DIR/caddy/Caddyfile"
[ -f "$caddyfile" ] || return 0
awk -v upstream="$upstream" '
BEGIN { depth = 0; candidate = ""; domain = ""; found = 0 }
{
line = $0
opens = gsub(/\{/, "{", line)
closes = gsub(/\}/, "}", line)
if (depth == 0 && opens > 0) {
header = $0
sub(/[[:space:]]*\{.*$/, "", header)
candidate = header
depth += opens - closes
next
}
if (depth > 0) {
if (index($0, "reverse_proxy " upstream) > 0) { found = 1; domain = candidate }
depth += opens - closes
next
}
}
END { if (found) print domain }
' "$caddyfile"
}
# Generic per-service removal: stops/removes its containers, its Caddy site # Generic per-service removal: stops/removes its containers, its Caddy site
# block (if any), any UFW rule tagged with its name, and optionally its # block (if any), any UFW rule tagged with its name, and optionally its
# ~/docker/<name> directory. Scoped to the common case (a Docker service # ~/docker/<name> directory. Scoped to the common case (a Docker service
@@ -766,6 +802,32 @@ write_readme() {
chown "$ACTUAL_USER:$ACTUAL_USER" "$dir/README.md" 2>/dev/null || true chown "$ACTUAL_USER:$ACTUAL_USER" "$dir/README.md" 2>/dev/null || true
} }
# Copies FILE to FILE.bak.<timestamp> if it already exists, right before a
# caller is about to overwrite it with a fresh `cat > FILE` heredoc. No-ops
# in DRY_RUN and silently no-ops if FILE doesn't exist yet (first install,
# nothing to save) — safe to call unconditionally right before every such
# write, fresh install or not.
#
# Confirmed live: install_frigate()'s fresh-install path overwrote a
# working, hand-crafted multi-container docker-compose.yml (Frigate +
# mosquitto + frigate-notify) with zero backup, because that file's shape
# didn't match what the service's own "existing install" detection knew
# how to recognize. Every service's own detection logic is a judgment call
# about what counts as "already installed" and can miss a real setup built
# outside this repo's own conventions — this exists as the safety net
# underneath that judgment call, not a replacement for it: call it right
# before any `cat > FILE` that could clobber something a user already has,
# so a wrong detection costs a `.bak` file to restore from instead of the
# original silently disappearing.
backup_if_exists() {
local file="$1"
[ "$DRY_RUN" = true ] && return 0
[ -f "$file" ] || return 0
local backup="${file}.bak.$(date +%Y%m%d-%H%M%S)"
cp -p "$file" "$backup" 2>/dev/null \
&& log_info "Backed up existing $(basename "$file") to $(basename "$backup")"
}
# ── Host port collision avoidance (shared by every service that publishes a # ── Host port collision avoidance (shared by every service that publishes a
# fixed host port) ──────────────────────────────────────────────────────────── # fixed host port) ────────────────────────────────────────────────────────────
# With 70+ services in this repo, several ship the same default port (e.g. # With 70+ services in this repo, several ship the same default port (e.g.
@@ -841,11 +903,19 @@ find_free_coturn_range() {
} }
# ── Caddy reverse-proxy wiring (shared by every web service) ───────────────── # ── Caddy reverse-proxy wiring (shared by every web service) ─────────────────
# Usage: configure_caddy_for_service "Name" "UPSTREAM" "default-subdomain" ["extra"] # Usage: configure_caddy_for_service "Name" "UPSTREAM" "default-subdomain" ["extra"] ["reverse_proxy-extra"]
# UPSTREAM: container:port for caddy_net routing (e.g. "filebrowser:80"), # UPSTREAM: container:port for caddy_net routing (e.g. "filebrowser:80"),
# or plain port number for localhost fallback (e.g. "8085"). # or plain port number for localhost fallback (e.g. "8085").
# The optional 5th arg is inserted as sub-directives *inside* the
# reverse_proxy block itself (e.g. " header_up X-Proxy-Secret abc123")
# — for the rare case a backend needs a header only reverse_proxy's own
# header_up can set, as opposed to EXTRA_CONFIG's auth-gate directives that
# run before reverse_proxy entirely. See services/frigate.sh's Authelia
# integration for the reference caller (pins X-Proxy-Secret so Frigate's
# proxy-auth trust can't be spoofed by a request that reaches it directly,
# bypassing Caddy/Authelia).
configure_caddy_for_service() { configure_caddy_for_service() {
local SERVICE_NAME="$1" SERVICE_UPSTREAM="$2" DEFAULT_SUBDOMAIN="$3" EXTRA_CONFIG="${4:-}" local SERVICE_NAME="$1" SERVICE_UPSTREAM="$2" DEFAULT_SUBDOMAIN="$3" EXTRA_CONFIG="${4:-}" REVERSE_PROXY_EXTRA="${5:-}"
# Out-params (not `local` — callers read these after the call returns) so # Out-params (not `local` — callers read these after the call returns) so
# a caller can tell whether Caddy actually ended up fronting the service # a caller can tell whether Caddy actually ended up fronting the service
@@ -856,6 +926,7 @@ configure_caddy_for_service() {
# already the only intended way in, instead of leaving both routes open. # already the only intended way in, instead of leaving both routes open.
CADDY_SERVICE_CONFIGURED=false CADDY_SERVICE_CONFIGURED=false
CADDY_SERVICE_MODE="" CADDY_SERVICE_MODE=""
CADDY_SERVICE_DOMAIN=""
# Derive the proxy upstream and a port number for display messages. # Derive the proxy upstream and a port number for display messages.
# Plain number → host.docker.internal:PORT (host-network or legacy # Plain number → host.docker.internal:PORT (host-network or legacy
@@ -916,6 +987,15 @@ configure_caddy_for_service() {
if [ -z "$SERVICE_DOMAIN" ]; then if [ -z "$SERVICE_DOMAIN" ]; then
echo " ⚠ No domain provided, skipping Caddy configuration."; return 0 echo " ⚠ No domain provided, skipping Caddy configuration."; return 0
fi fi
# Set as soon as we know a domain was actually accepted — every path below
# this point that returns 0 without configuring Caddy is a genuine failure
# (write/reload error), not "no domain chosen", so leaving this set is
# correct: the caller can tell CADDY_SERVICE_CONFIGURED apart from whether
# a domain was entered at all. Callers that pre-compute their own default
# URL/domain before calling this (e.g. services/mealie.sh's BASE_URL) need
# this to reconcile against whatever the user actually typed here, which
# can differ from that pre-computed default.
CADDY_SERVICE_DOMAIN="$SERVICE_DOMAIN"
# Build the site block — upstream differs by mode # Build the site block — upstream differs by mode
local _BLOCK_UPSTREAM="$_UPSTREAM" local _BLOCK_UPSTREAM="$_UPSTREAM"
@@ -931,6 +1011,15 @@ configure_caddy_for_service() {
_BLOCK_UPSTREAM="${_THIS_IP}:${_DISPLAY_PORT}" _BLOCK_UPSTREAM="${_THIS_IP}:${_DISPLAY_PORT}"
fi fi
# Bare "reverse_proxy upstream" unless a caller needs sub-directives
# (header_up, etc.) inside it — see the REVERSE_PROXY_EXTRA comment above.
local _REVERSE_PROXY_LINE="reverse_proxy ${_BLOCK_UPSTREAM}"
if [ -n "$REVERSE_PROXY_EXTRA" ]; then
_REVERSE_PROXY_LINE="reverse_proxy ${_BLOCK_UPSTREAM} {
${REVERSE_PROXY_EXTRA}
}"
fi
local _SITE_BLOCK local _SITE_BLOCK
_SITE_BLOCK="$(cat << CADDY_BLOCK _SITE_BLOCK="$(cat << CADDY_BLOCK
@@ -944,7 +1033,7 @@ ${SERVICE_DOMAIN} {
# after it would be dead code that never runs — full bypass regardless # after it would be dead code that never runs — full bypass regardless
# of what the auth server's own rules say. # of what the auth server's own rules say.
${EXTRA_CONFIG} ${EXTRA_CONFIG}
reverse_proxy ${_BLOCK_UPSTREAM} ${_REVERSE_PROXY_LINE}
# Security headers # Security headers
header { header {
+23 -3
View File
@@ -180,6 +180,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -219,7 +224,20 @@ _actualbudget_offer_authelia_oidc() {
[ -d "$DOCKER_DIR/authelia" ] || return 0 [ -d "$DOCKER_DIR/authelia" ] || return 0
declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0 declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0
grep -q '^ACTUAL_OPENID_DISCOVERY_URL=' "$DIR/.env" 2>/dev/null && return 0
# Confirmed live: a silent skip here (just `return 0`, no output) looked
# indistinguishable from the whole SSO step not running at all — a rerun
# against an .env that already had these vars (even from an earlier
# attempt that didn't fully complete) produced zero output, no prompt,
# nothing. Always say something instead, and offer to redo it.
if grep -q '^ACTUAL_OPENID_DISCOVERY_URL=' "$DIR/.env" 2>/dev/null; then
echo ""
log_info "Authelia SSO is already configured for Actual Budget (ACTUAL_OPENID_* already set in $DIR/.env)."
local RECONFIGURE=""
prompt_yn " Reconfigure it (registers a fresh Authelia client + secret)? (y/n):" "n" RECONFIGURE
[[ "$RECONFIGURE" =~ ^[Yy]$ ]] || return 0
sed -i '/^ACTUAL_OPENID_/d' "$DIR/.env"
fi
echo "" echo ""
local USE_SSO="" local USE_SSO=""
@@ -239,13 +257,13 @@ _actualbudget_offer_authelia_oidc() {
prompt_yn " Require two-factor for Actual Budget logins via Authelia too? (y/n):" "y" _2fa prompt_yn " Require two-factor for Actual Budget logins via Authelia too? (y/n):" "y" _2fa
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor" [[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
if ! _authelia_provision_oidc_client "ActualBudget" "actualbudget" "$AUTH_POLICY" "y" \ if ! _authelia_provision_oidc_client "ActualBudget" "actualbudget" "$AUTH_POLICY" "y" "n" "" \
"https://${AB_OIDC_DOMAIN}/openid/callback"; then "https://${AB_OIDC_DOMAIN}/openid/callback"; then
log_warning "Couldn't register Actual Budget as an OIDC client in Authelia — skipping SSO setup." log_warning "Couldn't register Actual Budget as an OIDC client in Authelia — skipping SSO setup."
return 0 return 0
fi fi
local _discovery_url="https://auth.${OIDC_AUTHELIA_DOMAIN}/.well-known/openid-configuration" local _discovery_url="${OIDC_AUTHELIA_PORTAL_URL}/.well-known/openid-configuration"
cat >> "$DIR/.env" << ENV cat >> "$DIR/.env" << ENV
# Written by services/actualbudget.sh's Authelia SSO step. The first OIDC # Written by services/actualbudget.sh's Authelia SSO step. The first OIDC
@@ -375,6 +393,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << AB_COMPOSE cat > docker-compose.yml << AB_COMPOSE
name: $CONTAINER name: $CONTAINER
@@ -392,6 +411,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
AB_COMPOSE AB_COMPOSE
backup_if_exists .env
cat > .env << AB_ENV cat > .env << AB_ENV
TZ=$TZ_VAL TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
+8
View File
@@ -170,6 +170,11 @@ CBLOCK
[[ "${DRY_RUN:-false}" == "true" ]] && return 0 [[ "${DRY_RUN:-false}" == "true" ]] && return 0
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
generate_password() { generate_password() {
local _len="${1:-32}" local _len="${1:-32}"
@@ -368,6 +373,7 @@ install_ai-gpu() {
sed -i "s|America/New_York|$TZ_VAL|g" {} \; sed -i "s|America/New_York|$TZ_VAL|g" {} \;
fi fi
backup_if_exists "$IMAGE_GEN_DIR/.env"
cat > "$IMAGE_GEN_DIR/.env" << IMGENV cat > "$IMAGE_GEN_DIR/.env" << IMGENV
# InvokeAI — image generation # InvokeAI — image generation
TZ=${TZ_VAL} TZ=${TZ_VAL}
@@ -407,6 +413,7 @@ IMGENV
local WEBUI_SECRET local WEBUI_SECRET
WEBUI_SECRET="$(generate_password 32)" WEBUI_SECRET="$(generate_password 32)"
backup_if_exists "$LLM_DIR/.env"
cat > "$LLM_DIR/.env" << LLMENV cat > "$LLM_DIR/.env" << LLMENV
# Ollama + Open WebUI + SearXNG # Ollama + Open WebUI + SearXNG
TZ=${TZ_VAL} TZ=${TZ_VAL}
@@ -441,6 +448,7 @@ LLMENV
fi fi
fi fi
backup_if_exists "$PORTAL_DIR/.env"
cat > "$PORTAL_DIR/.env" << PORTALENV cat > "$PORTAL_DIR/.env" << PORTALENV
# AI Portal — GPU stack swap controller # AI Portal — GPU stack swap controller
TZ=${TZ_VAL} TZ=${TZ_VAL}
+163
View File
@@ -10,6 +10,62 @@
less text (saves tokens), for both local and cloud models. less text (saves tokens), for both local and cloud models.
- Web search uses **DuckDuckGo** (no SearXNG in this build). - Web search uses **DuckDuckGo** (no SearXNG in this build).
## Hybrid workflow — local coding model + Claude Code
Split coding work by size, not by tool preference. This stack's local Ollama
coder model (the GPU generations table below has sizing per card) handles
fast, in-loop iteration — autocomplete, boilerplate, single-file refactors,
private/offline drafting, zero token cost. Claude Code (cloud) handles the
bigger, longer, cross-file work — architectural refactors, anything needing
full-repo context or stronger judgment — driven against this stack's Gitea
(or GitHub, via the `gitea-github-sync.sh` mirror in Roles above).
### Where to put instructions for each side
Claude Code loads `CLAUDE.md` in four tiers, concatenated broadest to most
specific — later tiers add to earlier ones, they don't replace them:
| Tier | Path | Put here |
|---|---|---|
| User | `~/.claude/CLAUDE.md` | Your personal conventions, true on *every* project — e.g. "CLI menus are numbered, `0` is always exit," "verify UI changes with Playwright," your code-style rules |
| Project | `./CLAUDE.md` or `./.claude/CLAUDE.md` | This codebase's own architecture/conventions, shared with collaborators via git (this file is the reference example) |
| Local | `./CLAUDE.local.md` (gitignored) | Your personal per-project notes — sandbox URLs, test data |
| One-off task | The prompt itself, handed over when you say "go" | The specific feature/idea for *this* build — never durable, don't put it in `CLAUDE.md` |
Write cross-project quirks into `~/.claude/CLAUDE.md` once — every project
inherits them automatically, no per-repo duplication needed. If it grows
past ~200 lines, split it into `~/.claude/rules/*.md` (still user-level,
loads before project-level rules).
### Claude Code reading from self-hosted Gitea
Two levels, depending on what you need:
- **Plain git — works today, nothing to install.** Claude Code's git
operations are shell `git` commands, not a GitHub-specific code path —
clone/push/pull against this stack's Gitea over SSH or an HTTPS token
exactly like any other remote. This only applies to a locally-run Claude
Code CLI against your own machine; a cloud/remote Claude Code session
(like the one used to write this doc) is scoped to whichever provider —
typically GitHub — it was attached to at session start, and can't reach
an arbitrary self-hosted Gitea on your LAN.
- **PR/issue/CI-level integration (optional).** Reading/commenting on Gitea
PRs and issues the way a GitHub MCP server does for GitHub needs an MCP
server that speaks Gitea's REST API. Gitea's own project publishes one —
`gitea/gitea-mcp` (gitea.com/gitea/gitea-mcp) — as a binary release, a
Docker image (`docker.gitea.com/gitea-mcp-server`), or `go run
gitea.com/gitea/gitea-mcp@latest`; it supports both stdio and HTTP
transport. Generate a token first — this stack's Gitea → profile →
Settings → Applications → Generate New Token (repo/api scopes) — then:
```bash
# stdio — simplest, one Claude Code CLI on this box
claude mcp add gitea --env GITEA_HOST=http://localhost:3001 \
--env GITEA_ACCESS_TOKEN=<token> -- gitea-mcp -t stdio
# or HTTP — one server, shared by multiple Claude Code clients
gitea-mcp -t http --port 8090 & # run once, e.g. alongside the stack
claude mcp add gitea http://localhost:8090/mcp \
--header "Authorization: Bearer <token>"
```
Not bundled by default — this stack's Gitea has no built-in Claude
integration out of the box; this is you adding it.
## GPU switcher (small local GPU only) ## GPU switcher (small local GPU only)
One small GPU can't run local chat and local image-gen at once. Swap it: One small GPU can't run local chat and local image-gen at once. Swap it:
```bash ```bash
@@ -39,6 +95,113 @@ bash pull-models.sh # pull Ollama models (run once after first install)
``` ```
Also a systemd unit: `sudo systemctl {start,stop,status} local-ai` Also a systemd unit: `sudo systemctl {start,stop,status} local-ai`
## Vision models (image understanding)
None of the tier-selected chat/code models above can read an image. `pull-models.sh`
offers one optional vision model at the end — pick it there, or pull one manually
any time:
```bash
docker exec ollama ollama pull moondream # or llava:7b / qwen2.5vl:7b / llama3.2-vision:11b
```
| Model | Size | Notes |
|-------|------|-------|
| `moondream` | ~1.7 GB | By Moondream AI — tiny, built for CPU-only or weak/old-GPU hardware. Best default if you don't have a real GPU. |
| `llava:7b` | ~4.7 GB | General-purpose vision, moderate resources. |
| `qwen2.5vl:7b` | ~6 GB | Stronger accuracy, needs more RAM/VRAM. |
| `llama3.2-vision:11b` | ~7.9 GB | Meta's vision model — heaviest of these four. |
Point any OpenAI-compatible app's vision/image-import feature at this stack's
Ollama endpoint with the pulled model. For Mealie's "import recipe from
photo" specifically — checked against docs.mealie.io directly, since Mealie
moved this off env vars at some point and old `OPENAI_*` env var guidance
for it is now stale: it's configured live in the UI, not `.env` —
**Group Settings → AI Providers** in Mealie itself, not this stack's
`.env` or `docker-compose.yml`. Add a provider with:
- `base_url`: `http://host.docker.internal:11434/v1` (Ollama publishes on
the host at `0.0.0.0:11434`, and Mealie is a separate compose project
not sharing a network with this stack, so it has to be reached over the
host the same way Caddy reaches bridge-mode services — see Mealie's own
compose: add `extra_hosts: ["host.docker.internal:host-gateway"]` to its
`mealie:` service if that hostname doesn't already resolve there. The
host's real LAN IP works too with no compose edit, just less stable
across DHCP renewals.)
- `api_key`: any non-empty placeholder — required by Mealie's form, ignored
by Ollama.
- model: the vision model just pulled (e.g. `moondream`).
Then mark that provider as the one used for image recognition (a separate
toggle from the general default-provider setting) — that's what actually
turns on the photo-import feature. No Mealie container restart needed, it
applies live. See Open WebUI → Settings → Connections if you'd rather
confirm the local base URL/model name there first.
## NVIDIA server-GPU generations — capability reference
What a given datacenter GPU generation can actually run through this stack
(Ollama for chat/code, ComfyUI/InvokeAI for images), since it's VRAM- and
tensor-core-bound per generation. Only Ampere and newer have native BF16
tensor cores; llama.cpp/Ollama's CUDA backend supports Pascal (compute
capability 6.0) and up, so quantized chat/coding model size mostly comes
down to VRAM capacity — older cards just run slower per token, with no
flash-attention-class kernel path.
| Generation | Example server cards | VRAM | Flux 2 (32B DiT) | Flux.1 / SDXL | Chat (GGUF, Ollama) | Coding (GGUF, Ollama) |
|---|---|---|---|---|---|---|
| Blackwell (2024-25) | B100 / B200 / GB200 | 180-192GB HBM3e | Yes — FP8 fast, native | Yes, fast | 70B+ at high precision, easily | Any coder model, full precision |
| Hopper (2022) | H100 / H200 | 80-141GB HBM3 | Yes — FP8 native tensor cores; the target generation | Yes, fast | 70B in Q4-Q8 comfortably | Qwen2.5-Coder-32B / DeepSeek-Coder-V2, full precision |
| Ampere (2020) | A100 40/80GB | 40-80GB HBM2e | Minimum viable — FP8 checkpoint (~32GB) fits the 80GB card; no native FP8 tensor cores, so it's upcast/emulated rather than accelerated | Yes, comfortable (native BF16/TF32) | 70B Q4 (~40GB) fits the 80GB card with room; 30-34B comfortable on the 40GB card | Qwen2.5-Coder-32B / Codestral-22B comfortable |
| Volta (2017) | V100 16/32GB | 16-32GB HBM2 | No — even the 32GB card has no headroom for the FP8 checkpoint plus activations | FLUX.1-dev FP8 (~18-23GB) fits the 32GB card, tight; SDXL/SD1.5 fine (first-gen FP16 tensor cores) | 32GB card: 30-34B Q4 comfortable, 70B tight/needs multi-GPU. 16GB card: 13-14B comfortable | 32B coder models fit the 32GB card in Q4 |
| Pascal (2016) | P100 16GB / P40 24GB | 16-24GB HBM2/GDDR5 | No | SD1.5 fine; SDXL runs but slow — no tensor cores at all, weak/emulated FP16 (worse on the P40 than the P100) | Same VRAM math as Ampere/Volta at matched capacity (P40 24GB ≈ 30B Q4), but noticeably slower tokens/sec | 32B coder Q4 fits the P40 24GB capacity-wise; fine for batch/background, not snappy interactive autocomplete |
| Maxwell (2014) | M40 / M60 24GB | 8-24GB GDDR5 | No | Impractical — SD1.5 only, very slow; no real FP16 tensor path | 7B-13B Q4 runs but slow | 7B-class coder models only — a novelty, not a daily driver |
**CUDA 13 has already dropped Pascal/Volta** (this happened, it's not a future
warning anymore) — but that's the *toolkit*, not the driver, and it doesn't
block this stack: Docker GPU passthrough only needs the host *driver* to
recognize the card, since prebuilt inference images (Ollama, ComfyUI, etc.)
already bundle whatever CUDA runtime they need internally. The driver is the
part to get right. **NVIDIA has named R580 the last driver branch that adds
Volta/Pascal support** (P100/P40/V100 explicitly listed), supported into
~June 2028 — pin to R580 explicitly rather than trusting `ubuntu-drivers
autoinstall`'s default pick on a fresh/newer Ubuntu install, since a later
branch may no longer initialize these cards at all. Also confirm you land on
the **proprietary** driver package, not an `-open` one — NVIDIA's open-source
kernel modules only support Turing and newer, so Volta/Pascal *require* the
closed-source module; `ubuntu-drivers devices` should recommend the right one
for the card it detects, but double-check rather than assume on a distro
release that defaults newer GPUs to `-open`. None of this is something
`require_docker` handles — it installs Docker/Compose only; the NVIDIA
driver and `nvidia-container-toolkit` are still on you to install first,
and getting the driver branch right is what actually matters here, not the
Ubuntu version itself.
**"Tesla"-branded card power connector — don't assume standard PCIe.**
("Tesla" here is NVIDIA's old datacenter-card *brand name*, retired after
Volta — not the unrelated, much older Tesla *microarchitecture* that
predates Fermi/Kepler/Maxwell/Pascal/Volta. V100/P100/P40/M40 all shipped
under the Tesla brand despite being four different architecture
generations.) These PCIe cards take an 8-pin **CPU/EPS12V** connector, not
the 6+2-pin PCIe
connector a normal GPU uses — a standard PCIe cable will not plug in. Get the
dongle/adapter (splits a PCIe 8-pin into EPS12V, or use a real EPS cable) and
never daisy-chain both 8-pin rails off one PSU cable/splitter — use two
separate cable runs. These cards are also passively cooled (built for server
chassis airflow, no onboard fan) — a tower case needs a shroud + dedicated
fan blowing through the heatsink fins, and there's no display output, which
is a non-issue on a headless box like this but worth knowing going in.
**MoE models are the exception that gives Pascal/Volta real life for coding.**
The "coding" column above assumes dense models, where token speed tracks the
full parameter count — exactly where Pascal/Volta's missing or first-gen
tensor cores hurt most. A mixture-of-experts model breaks that link: VRAM is
still set by *total* params (every expert has to be resident — no memory
saving from sparsity), but compute per token is set by *active* params only.
`qwen3-coder:30b-a3b` in `ollama pull` is the concrete case — 30B total, only
~3.3B active per token (128 experts, 8 routed) — so it needs the same ~19GB
VRAM (Q4_K_M) as a dense 30B model but computes like a dense ~3B one. That's
light enough that Pascal/Volta's weak tensor cores barely matter, making it
the best coding model to put on a P40 24GB or a V100 — a dense 32B coder on
the same card would be noticeably slower for no quality gain. Mixtral 8x7B
(46.7B total / ~13B active, ~24-26GB at Q4) is the same trade at a larger
size — fits Volta 32GB or Ampere, with the same active-vs-total gap.
## Cloud LLM providers (Open WebUI) ## Cloud LLM providers (Open WebUI)
Open WebUI uses an OpenAI-compatible connection list. The local RAG server is the Open WebUI uses an OpenAI-compatible connection list. The local RAG server is the
first entry; any cloud providers added at install follow it. Two semicolon-separated first entry; any cloud providers added at install follow it. Two semicolon-separated
+93 -2
View File
@@ -34,6 +34,8 @@ install_ai-stack() {
echo "[DRY-RUN] Would copy vendored source $SRC_DIR -> $AS_DIR" echo "[DRY-RUN] Would copy vendored source $SRC_DIR -> $AS_DIR"
echo "[DRY-RUN] Would optionally collect cloud LLM provider keys (Groq/DeepInfra/OpenAI/OpenRouter)" echo "[DRY-RUN] Would optionally collect cloud LLM provider keys (Groq/DeepInfra/OpenAI/OpenRouter)"
echo "[DRY-RUN] Would run the app installer local-ai-setup.sh (Docker/NVIDIA toolkit, VRAM-aware models, generates compose/.env, starts stack, registers systemd 'local-ai')" echo "[DRY-RUN] Would run the app installer local-ai-setup.sh (Docker/NVIDIA toolkit, VRAM-aware models, generates compose/.env, starts stack, registers systemd 'local-ai')"
echo "[DRY-RUN] Would offer an optional vision-capable model to pull (moondream/llava/qwen2.5vl/llama3.2-vision) via the generated pull-models.sh"
echo "[DRY-RUN] Would offer to stop optional services not wanted (Gitea/Portainer/Kiwix/InvokeAI/ComfyUI/Aider) after the full stack starts"
echo "[DRY-RUN] Would wire cloud providers into Open WebUI (OPENAI_API_BASE_URLS) preserving the local RAG connection" echo "[DRY-RUN] Would wire cloud providers into Open WebUI (OPENAI_API_BASE_URLS) preserving the local RAG connection"
echo "[DRY-RUN] Would write gpu-mode.sh and optionally enable the GPU switcher (one small GPU shared by Ollama and InvokeAI/ComfyUI)" echo "[DRY-RUN] Would write gpu-mode.sh and optionally enable the GPU switcher (one small GPU shared by Ollama and InvokeAI/ComfyUI)"
echo "[DRY-RUN] Would attach Open WebUI to caddy_net and configure Caddy (open-webui:8080, host port 3000)" echo "[DRY-RUN] Would attach Open WebUI to caddy_net and configure Caddy (open-webui:8080, host port 3000)"
@@ -67,12 +69,13 @@ install_ai-stack() {
log_info " 2) DeepInfra Cheapest host for open models, zero-retention. Key: https://deepinfra.com/dash/api_keys" log_info " 2) DeepInfra Cheapest host for open models, zero-retention. Key: https://deepinfra.com/dash/api_keys"
log_info " 3) OpenAI GPT-5.x, o-series, gpt-image. Key: https://platform.openai.com/api-keys" log_info " 3) OpenAI GPT-5.x, o-series, gpt-image. Key: https://platform.openai.com/api-keys"
log_info " 4) OpenRouter One key, 300+ models. Key: https://openrouter.ai/keys" log_info " 4) OpenRouter One key, 300+ models. Key: https://openrouter.ai/keys"
log_info " 0) Skip — stay fully local"
echo "" echo ""
log_info " Example: '1 2' wires Groq + DeepInfra. Leave blank to stay fully local." log_info " Example: '1 2' wires Groq + DeepInfra."
echo "" echo ""
local CLOUD_CHOICES="" local CLOUD_CHOICES=""
prompt_text "Cloud providers to add []:" "" CLOUD_CHOICES prompt_text "Cloud providers to add (0 or blank = skip, stay fully local):" "" CLOUD_CHOICES
# Parallel arrays: display name, OpenAI-compatible base URL, and entered key # Parallel arrays: display name, OpenAI-compatible base URL, and entered key
declare -a CLOUD_NAMES=() CLOUD_URLS=() CLOUD_KEYS=() declare -a CLOUD_NAMES=() CLOUD_URLS=() CLOUD_KEYS=()
@@ -80,6 +83,7 @@ install_ai-stack() {
for _c in $CLOUD_CHOICES; do for _c in $CLOUD_CHOICES; do
_cname="" ; _curl="" _cname="" ; _curl=""
case "$_c" in case "$_c" in
0) continue ;;
1) _cname="Groq"; _curl="https://api.groq.com/openai/v1" ;; 1) _cname="Groq"; _curl="https://api.groq.com/openai/v1" ;;
2) _cname="DeepInfra"; _curl="https://api.deepinfra.com/v1/openai" ;; 2) _cname="DeepInfra"; _curl="https://api.deepinfra.com/v1/openai" ;;
3) _cname="OpenAI"; _curl="https://api.openai.com/v1" ;; 3) _cname="OpenAI"; _curl="https://api.openai.com/v1" ;;
@@ -121,6 +125,93 @@ install_ai-stack() {
log_info "Skipped. Run later: cd $AS_DIR && bash local-ai-setup.sh" log_info "Skipped. Run later: cd $AS_DIR && bash local-ai-setup.sh"
fi fi
# local-ai-setup.sh runs as whoever invoked this wrapper — root, since
# setup.sh itself is run via sudo — so everything it just generated
# (docker-compose.yml, .env, requirements.txt, server.py, pull-models.sh,
# etc.) comes out root-owned. Hand it back to ACTUAL_USER unconditionally,
# not just on the cloud-provider path below. Confirmed live: without this,
# re-running local-ai-setup.sh directly later (the update path, plain user,
# no sudo — exactly what its own "run later" message above tells you to do)
# fails with "Permission denied" on any file the first root-run created,
# e.g. requirements.txt.
ensure_docker_dir_ownership "$AS_DIR"
# ── Optional services — not everyone wants the whole stack running ────────
# local-ai-setup.sh above always generates and starts every service in the
# stack unconditionally — Ollama/Open WebUI/ChromaDB/RAG/MCP (the core) plus
# Gitea, Portainer, Kiwix, InvokeAI, ComfyUI, and Aider. Several of those
# are genuinely optional depending on the box — e.g. Gitea when you already
# run git elsewhere, or Portainer when you manage Docker some other way.
# Rather than make local-ai-setup.sh's own compose generation conditional
# (risky: it's vendored upstream code, and other services reference these
# by container name/network in ways that would need auditing one by one),
# just stop the ones not wanted after the fact — images are already pulled
# either way, and `docker compose up -d <name>` brings any of them back
# later with no reinstall needed. User feedback: wanted this choice instead
# of always getting the full stack.
if [ "$INSTALLER_RAN" = true ]; then
echo ""
log_info "The full stack is running. Some of these are genuinely optional —"
log_info "stop the ones you don't need (start any of them again later with"
log_info "'docker compose up -d <service>', no reinstall required):"
echo ""
echo " 1) Gitea — skip if you already run git elsewhere"
echo " 2) Portainer — skip if you manage Docker some other way"
echo " 3) Kiwix — offline Wikipedia/docs server"
echo " 4) InvokeAI — image generation (SD/SDXL/Flux)"
echo " 5) ComfyUI — image generation (node-based)"
echo " 6) Aider — AI pair-programming CLI"
echo " 7) RAG/MCP stack — ChromaDB + rag-server + mcp-server, for Open WebUI's"
echo " RAG tab and MCP tool-calling. Skip if you don't use"
echo " those — plain Ollama chat in Open WebUI (and anything"
echo " else, like Mealie, talking to Ollama directly) works"
echo " fine without this; only that one tab needs it."
echo ""
local STOP_CHOICES=""
prompt_text "Stop which of these? (space-separated numbers, blank to keep everything running):" "" STOP_CHOICES
local _s _svc
declare -a _TO_STOP=()
local _stopping_kiwix=false
for _s in $STOP_CHOICES; do
case "$_s" in
1) _TO_STOP+=("gitea") ;;
2) _TO_STOP+=("portainer") ;;
3) _TO_STOP+=("kiwix"); _stopping_kiwix=true ;;
4) _TO_STOP+=("invokeai") ;;
5) _TO_STOP+=("comfyui") ;;
6) _TO_STOP+=("aider") ;;
# Bundled, not three separate numbers: mcp-server depends_on
# rag-server which depends_on chromadb, so stopping only one
# of the three leaves the others running against a dead
# dependency instead of a clean, fully-stopped chain.
7) _TO_STOP+=("mcp-server" "rag-server" "chromadb") ;;
*) log_warning "Ignoring unknown choice '$_s'"; continue ;;
esac
done
# mcp-server also depends_on kiwix (not just rag-server) — stopping
# kiwix without also stopping mcp-server leaves it running against a
# dependency that's down, the same inconsistent state option 7 above
# is written to avoid. Cascade automatically rather than trust the
# user to notice the same rule applies here too.
if [ "$_stopping_kiwix" = true ] && [[ ! " ${_TO_STOP[*]} " == *" mcp-server "* ]]; then
log_info "Kiwix is also a dependency of mcp-server — stopping that too."
_TO_STOP+=("mcp-server")
fi
if [ ${#_TO_STOP[@]} -gt 0 ]; then
# Dedupe in case option 7 and the kiwix cascade both added mcp-server.
local -a _TO_STOP_UNIQUE=()
local _seen=" "
for _svc in "${_TO_STOP[@]}"; do
[[ "$_seen" == *" $_svc "* ]] && continue
_TO_STOP_UNIQUE+=("$_svc")
_seen+="$_svc "
done
(cd "$AS_DIR" && docker compose stop "${_TO_STOP_UNIQUE[@]}") \
&& log_success "Stopped: ${_TO_STOP_UNIQUE[*]} (images still pulled — bring any back with: docker compose up -d <name>)" \
|| log_warning "Couldn't stop one or more services — check: docker compose ps"
fi
fi
# ── Wire cloud providers into the generated compose ─────────────────────── # ── Wire cloud providers into the generated compose ───────────────────────
if [ ${#CLOUD_NAMES[@]} -gt 0 ] && [ -f "$AS_DIR/docker-compose.yml" ]; then if [ ${#CLOUD_NAMES[@]} -gt 0 ] && [ -f "$AS_DIR/docker-compose.yml" ]; then
# Prepend the local RAG connection so RAG keeps working, then the clouds. # Prepend the local RAG connection so RAG keeps working, then the clouds.
+7
View File
@@ -174,6 +174,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -240,6 +245,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << ABCOMPOSE cat > docker-compose.yml << ABCOMPOSE
name: archivebox name: archivebox
@@ -262,6 +268,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
ABCOMPOSE ABCOMPOSE
backup_if_exists .env
cat > .env << ABENV cat > .env << ABENV
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
ABENV ABENV
+7
View File
@@ -188,6 +188,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -278,6 +283,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << ARM_COMPOSE cat > docker-compose.yml << ARM_COMPOSE
name: arm name: arm
@@ -306,6 +312,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
ARM_COMPOSE ARM_COMPOSE
backup_if_exists .env
cat > .env << ARM_ENV cat > .env << ARM_ENV
ARM_OUTPUT=$ARM_OUTPUT ARM_OUTPUT=$ARM_OUTPUT
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
+413 -117
View File
@@ -255,6 +255,11 @@ CBLOCK
[[ "${DRY_RUN:-false}" == "true" ]] && return 0 [[ "${DRY_RUN:-false}" == "true" ]] && return 0
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
generate_password() { generate_password() {
local _len="${1:-32}" local _len="${1:-32}"
@@ -298,6 +303,21 @@ register_service asterisk homelab "Easy Asterisk PBX (intercom/VoIP; auto-tunes
# naming. Every sibling service in this repo (pstn-trunk, security-dashboard, # naming. Every sibling service in this repo (pstn-trunk, security-dashboard,
# crowdsec) already probes for both directories, so both layouts stay fully # crowdsec) already probes for both directories, so both layouts stay fully
# supported without further special-casing. # supported without further special-casing.
#
# Container name itself: new installs use the plain "asterisk" (matching
# every other service's own container_name == service name convention —
# "easy-" was this repo's install-time container name before, left over from
# when the vendor CLI tool's own name (`easy-asterisk`, still installed at
# /usr/local/bin/easy-asterisk inside the container — unrelated, never
# renamed) got reused for the container too. A box that already has a
# running container is read directly from its own docker-compose.yml instead
# of assumed from the directory, so an existing "easy-asterisk" install
# keeps working with no silent rename — same reasoning as the droplet-layout
# preservation above, just one level down (container name, not directory).
# Migrating an existing box to the new name is a deliberate, one-time action
# (edit docker-compose.yml's container_name + coturn, `docker compose down`
# + `up -d`) — once done, every sibling service here re-reads it from that
# same file and follows automatically, no further changes needed anywhere.
_asterisk_resolve_layout() { _asterisk_resolve_layout() {
if [[ -f "$DOCKER_DIR/asterisk-digital-ocean/docker-compose.yml" ]]; then if [[ -f "$DOCKER_DIR/asterisk-digital-ocean/docker-compose.yml" ]]; then
ASTERISK_DIR="$DOCKER_DIR/asterisk-digital-ocean" ASTERISK_DIR="$DOCKER_DIR/asterisk-digital-ocean"
@@ -306,12 +326,30 @@ _asterisk_resolve_layout() {
ASTERISK_PROJECT="asterisk-do" ASTERISK_PROJECT="asterisk-do"
else else
ASTERISK_DIR="$DOCKER_DIR/asterisk" ASTERISK_DIR="$DOCKER_DIR/asterisk"
ASTERISK_CONTAINER="easy-asterisk"
ASTERISK_COTURN="easy-asterisk-coturn"
ASTERISK_PROJECT="asterisk" ASTERISK_PROJECT="asterisk"
ASTERISK_CONTAINER=""
if [[ -f "$ASTERISK_DIR/docker-compose.yml" ]]; then
ASTERISK_CONTAINER="$(grep -m1 '^[[:space:]]*container_name:' "$ASTERISK_DIR/docker-compose.yml" | awk '{print $2}')"
fi
[[ -z "$ASTERISK_CONTAINER" ]] && ASTERISK_CONTAINER="asterisk"
ASTERISK_COTURN="${ASTERISK_CONTAINER}-coturn"
fi fi
} }
# Live public-IP detection, no prompts — the same DO-metadata -> ifconfig.me
# -> `hostname -I` fallback chain _asterisk_detect_digitalocean uses when
# actually setting up a droplet, factored out for every OTHER caller that
# just needs "what's this box's public IP right now" without the
# interactive droplet-mode question attached (the archive-restore IP-patch
# below, and _asterisk_run_stack_health_check's IP-mismatch check).
_asterisk_current_public_ip() {
local ip=""
ip="$(curl -fsS --max-time 2 http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address 2>/dev/null || true)"
[[ -z "$ip" ]] && ip="$(curl -fsS --max-time 3 https://ifconfig.me 2>/dev/null || true)"
[[ -z "$ip" ]] && ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
echo "$ip"
}
# ── DigitalOcean droplet detection ───────────────────────────────────────── # ── DigitalOcean droplet detection ─────────────────────────────────────────
# Sets IS_DO (true/false), DROPLET_ID and PUBLIC_IP. # Sets IS_DO (true/false), DROPLET_ID and PUBLIC_IP.
# #
@@ -667,9 +705,7 @@ case "$cmd" in
PJSIP_CONF="$HERE/config/asterisk/pjsip.conf" PJSIP_CONF="$HERE/config/asterisk/pjsip.conf"
if [ -f "$PJSIP_CONF" ]; then if [ -f "$PJSIP_CONF" ]; then
OLD_EXT_IP="$(grep -m1 '^external_signaling_address=' "$PJSIP_CONF" | cut -d= -f2)" OLD_EXT_IP="$(grep -m1 '^external_signaling_address=' "$PJSIP_CONF" | cut -d= -f2)"
NEW_EXT_IP="$(curl -fsS --max-time 2 http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address 2>/dev/null || true)" NEW_EXT_IP="$(_asterisk_current_public_ip)"
[ -z "$NEW_EXT_IP" ] && NEW_EXT_IP="$(curl -fsS --max-time 3 https://ifconfig.me 2>/dev/null || true)"
[ -z "$NEW_EXT_IP" ] && NEW_EXT_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
if [ -n "$OLD_EXT_IP" ] && [ -n "$NEW_EXT_IP" ] && [ "$OLD_EXT_IP" != "$NEW_EXT_IP" ]; then if [ -n "$OLD_EXT_IP" ] && [ -n "$NEW_EXT_IP" ] && [ "$OLD_EXT_IP" != "$NEW_EXT_IP" ]; then
echo "This archive's SIP config was for a different box's public IP" echo "This archive's SIP config was for a different box's public IP"
@@ -1155,6 +1191,48 @@ _asterisk_patch_voicemail_vendor_files() {
log_success "Vendor generator functions patched for voicemail access codes." log_success "Vendor generator functions patched for voicemail access codes."
} }
# ── easy-asterisk CLI: non-interactive Caddy cert sync ──────────────────────
# Adds a --sync-caddy-cert flag to the deployed easy-asterisk.sh/
# easy-asterisk-v0.10.0.sh copies, mirroring the vendor script's own
# --rebuild-dialplan/--write-web-admin-script non-interactive entry points
# (see their own comments a few lines up in the vendor file). Needed so
# _asterisk_run_stack_health_check() below can trigger
# setup_caddy_cert_sync() — the same function "Server Settings -> Force
# re-sync Caddy certs" calls in the interactive menu, which finds Caddy's
# already-issued cert for DOMAIN_NAME and copies it into
# /etc/asterisk/certs so the transport-tls PJSIP transport can actually
# bind — from the host via `docker exec`, instead of only being reachable
# by a human sitting at the interactive CLI. Patches the deployed copy
# only (never vendor/ in git), same convention as
# _asterisk_patch_voicemail_vendor_files and friends.
_asterisk_patch_cert_sync_cli() {
local EA_DIR="$1"
local EASY1="$EA_DIR/easy-asterisk.sh"
local EASY2
EASY2="$(find "$EA_DIR" -maxdepth 1 -name 'easy-asterisk-v*.sh' | head -1)"
[[ -z "$EASY2" ]] && EASY2="$EA_DIR/easy-asterisk-v0.10.0.sh"
local f
for f in "$EASY1" "$EASY2"; do
[[ -f "$f" ]] || continue
grep -q -- '--sync-caddy-cert' "$f" && continue
if ! grep -q '^# Non-interactive entry point used by the container entrypoint on every$' "$f"; then
log_warning "$(basename "$f"): non-interactive-entrypoint anchor not found — vendor template changed upstream."
log_warning " Add a --sync-caddy-cert flag branch calling setup_caddy_cert_sync \"auto\" manually (see this installer's comment)."
continue
fi
sed -i '/^# Non-interactive entry point used by the container entrypoint on every$/i\
# Non-interactive entry point so services/asterisk.sh'"'"'s host-side stack\
# health check can trigger a Caddy cert re-sync without a human at the\
# interactive CLI menu (Server Settings -> "Force re-sync Caddy certs").\
if [[ "${1:-}" == "--sync-caddy-cert" ]]; then\
setup_caddy_cert_sync "auto"\
exit 0\
fi\
' "$f"
done
}
# ── asterisk.conf: live_dangerously ───────────────────────────────────────── # ── asterisk.conf: live_dangerously ─────────────────────────────────────────
# pstn-trunk.sh's dialplan leans on AST_CONFIG() for everything permission- # pstn-trunk.sh's dialplan leans on AST_CONFIG() for everything permission-
# related (pstn-permissions.conf tiers, pstn-trunk-killswitch.conf) — see # related (pstn-permissions.conf tiers, pstn-trunk-killswitch.conf) — see
@@ -1230,7 +1308,7 @@ _asterisk_ensure_live_voicemail_include() {
docker exec "$CONTAINER_NAME" asterisk -rx "dialplan reload" &>/dev/null || true docker exec "$CONTAINER_NAME" asterisk -rx "dialplan reload" &>/dev/null || true
} }
# ── pjsip.conf: [transport-tls] keep_alive_interval ───────────────────────── # ── pjsip.conf: [global] keep_alive_interval ─────────────────────────────────
# Mitigation for WiFi/LAN SIP clients (e.g. Sipnetic on Android) dropping # Mitigation for WiFi/LAN SIP clients (e.g. Sipnetic on Android) dropping
# their TLS registration every few seconds on some hosts but not others — # their TLS registration every few seconds on some hosts but not others —
# confirmed live on an IONOS VPS (never reproduced on a DigitalOcean droplet # confirmed live on an IONOS VPS (never reproduced on a DigitalOcean droplet
@@ -1239,15 +1317,28 @@ _asterisk_ensure_live_voicemail_include() {
# live disconnect and CrowdSec/packet-loss both ruled out first. Leading # live disconnect and CrowdSec/packet-loss both ruled out first. Leading
# theory: an idle-connection timeout somewhere in IONOS's own network # theory: an idle-connection timeout somewhere in IONOS's own network
# virtualization layer, below anything client-side tools can see. PJSIP's # virtualization layer, below anything client-side tools can see. PJSIP's
# `keep_alive_interval` sends a lightweight double-CRLF over the transport's # `keep_alive_interval` sends a lightweight double-CRLF over connection-oriented
# TLS connection on a timer, which is the standard fix for exactly this # transports (TCP/TLS) on a timer, which is the standard fix for exactly this
# class of "idle SIP/TLS connection gets silently dropped" symptom. # class of "idle SIP/TLS connection gets silently dropped" symptom.
# #
# This is a *transport* option, not an endpoint/AOR option — `qualify_frequency` # **This is a `type=global` option, not a `type=transport` option**it does
# (already set globally) is an endpoint-level OPTIONS ping that re-establishes # not exist on `[transport-tls]`/`[transport-tcp]` objects at all, on any
# a dropped connection, it doesn't stop the drop from happening in the first # Asterisk version. An earlier version of this patch inserted it into
# place. `rtp_keepalive` (mobile devices) is unrelated: RTP media keepalive # `[transport-tls]` (right after `protocol=tls`), which sorcery always
# during an active call, not SIP signaling connection keepalive while idle. # rejects: "Could not find option suitable for category 'transport-tls'
# named 'keep_alive_interval'" — and rejecting the option means the whole
# `type=transport` object fails to be created, so `transport-tls` never
# binds at all. Confirmed live: this silently took down TLS SIP entirely on
# a box that had picked up the patch, reproducing the exact same "network
# error on all calls" symptom the rest of this file's health check exists to
# catch. The anchor below targets `type=global` inside `[global]` instead.
#
# This is a *transport-behavior* option, not an endpoint/AOR option —
# `qualify_frequency` (already set globally) is an endpoint-level OPTIONS
# ping that re-establishes a dropped connection, it doesn't stop the drop
# from happening in the first place. `rtp_keepalive` (mobile devices) is
# unrelated: RTP media keepalive during an active call, not SIP signaling
# connection keepalive while idle.
_asterisk_patch_keepalive_vendor_files() { _asterisk_patch_keepalive_vendor_files() {
local EA_DIR="$1" local EA_DIR="$1"
local ENTRYPOINT="$EA_DIR/docker/entrypoint.sh" local ENTRYPOINT="$EA_DIR/docker/entrypoint.sh"
@@ -1259,15 +1350,20 @@ _asterisk_patch_keepalive_vendor_files() {
for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do
[[ -f "$f" ]] || continue [[ -f "$f" ]] || continue
if ! grep -q '^protocol=tls$' "$f"; then # Undo the old, incorrect [transport-tls] placement if an earlier
log_warning "$(basename "$f"): '[transport-tls]' anchor not found — vendor template changed upstream." # run of this function already patched it there.
log_warning " Add 'keep_alive_interval=15' manually inside [transport-tls] in this file's pjsip.conf heredoc." if grep -q '^protocol=tls$' "$f" && grep -A2 '^protocol=tls$' "$f" | grep -q '^keep_alive_interval='; then
sed -i '/^protocol=tls$/{n;/^keep_alive_interval=/d}' "$f"
fi
if ! grep -q '^type=global$' "$f"; then
log_warning "$(basename "$f"): '[global]' anchor not found — vendor template changed upstream."
log_warning " Add 'keep_alive_interval=15' manually inside [global] (not [transport-tls]) in this file's pjsip.conf heredoc."
continue continue
fi fi
grep -q '^keep_alive_interval=' "$f" || sed -i '/^protocol=tls$/a keep_alive_interval=15' "$f" grep -q '^keep_alive_interval=' "$f" || sed -i '/^type=global$/a keep_alive_interval=15' "$f"
done done
log_success "Vendor generator functions patched for TLS transport keepalive." log_success "Vendor generator functions patched for SIP keepalive (pjsip.conf [global])."
} }
# Live-file counterpart to the vendor-template patch above, same reasoning # Live-file counterpart to the vendor-template patch above, same reasoning
@@ -1278,20 +1374,38 @@ _asterisk_patch_keepalive_vendor_files() {
# a transport object — PJSIP transports are bound at module load, not # a transport object — PJSIP transports are bound at module load, not
# reloadable via sorcery like endpoints/AORs are — so this restarts the # reloadable via sorcery like endpoints/AORs are — so this restarts the
# container rather than issuing a reload, same as _asterisk_ensure_live_dangerously. # container rather than issuing a reload, same as _asterisk_ensure_live_dangerously.
#
# `keep_alive_interval` belongs in `[global]` (`type=global`), not
# `[transport-tls]` — see the comment on _asterisk_patch_keepalive_vendor_files
# for why the old placement made sorcery reject the transport object outright
# (killing TLS SIP entirely, not just the keepalive). This also self-heals a
# box that already has the bad `[transport-tls]` entry from before that fix.
_asterisk_ensure_live_keepalive() { _asterisk_ensure_live_keepalive() {
local EA_DIR="$1" CONTAINER_NAME="$2" local EA_DIR="$1" CONTAINER_NAME="$2"
local CONF_LIVE="$EA_DIR/config/asterisk/pjsip.conf" local CONF_LIVE="$EA_DIR/config/asterisk/pjsip.conf"
[[ -f "$CONF_LIVE" ]] || return 0 [[ -f "$CONF_LIVE" ]] || return 0
if ! grep -q '^\[transport-tls\]$' "$CONF_LIVE"; then
log_warning "Couldn't find '[transport-tls]' in the live pjsip.conf — add" local CHANGED=false
if sed -n '/^\[transport-tls\]$/,/^\[/{/^keep_alive_interval=/p}' "$CONF_LIVE" | grep -q .; then
sed -i '/^\[transport-tls\]$/,/^\[/{/^keep_alive_interval=/d}' "$CONF_LIVE"
log_warning "Removed 'keep_alive_interval' from [transport-tls] — that option doesn't exist on a PJSIP"
log_warning "transport object and was making the whole TLS transport fail to bind. Moving it to [global]."
CHANGED=true
fi
if ! grep -q '^\[global\]$' "$CONF_LIVE"; then
log_warning "Couldn't find '[global]' in the live pjsip.conf — add"
log_warning "'keep_alive_interval=15' manually inside that section, then: docker restart ${CONTAINER_NAME}" log_warning "'keep_alive_interval=15' manually inside that section, then: docker restart ${CONTAINER_NAME}"
return 0 return 0
fi fi
grep -q '^keep_alive_interval=' "$CONF_LIVE" && return 0 if ! grep -q '^keep_alive_interval=' "$CONF_LIVE"; then
sed -i '/^\[global\]$/,/^\[/{/^type=global$/a keep_alive_interval=15
sed -i '/^\[transport-tls\]$/,/^\[/{/^protocol=tls$/a keep_alive_interval=15
}' "$CONF_LIVE" }' "$CONF_LIVE"
log_success "Patched keep_alive_interval=15 into the live pjsip.conf's [transport-tls] transport." log_success "Patched keep_alive_interval=15 into the live pjsip.conf's [global] section."
CHANGED=true
fi
[[ "$CHANGED" == true ]] || return 0
log_info "Restarting Asterisk to apply (transport options aren't picked up by a reload)..." log_info "Restarting Asterisk to apply (transport options aren't picked up by a reload)..."
docker restart "$CONTAINER_NAME" &>/dev/null \ docker restart "$CONTAINER_NAME" &>/dev/null \
@@ -1479,6 +1593,7 @@ _asterisk_write_compose() {
" "
[[ "$USE_EMBEDDED_COTURN" != true ]] && _COTURN_DEPENDS="" && _COTURN_SERVICE="" [[ "$USE_EMBEDDED_COTURN" != true ]] && _COTURN_DEPENDS="" && _COTURN_SERVICE=""
backup_if_exists docker-compose.yml
cat > docker-compose.yml << EOF cat > docker-compose.yml << EOF
name: PROJECT_NAME_PLACEHOLDER name: PROJECT_NAME_PLACEHOLDER
@@ -1554,115 +1669,93 @@ EOF
_asterisk_configure_caddy_public() { _asterisk_configure_caddy_public() {
local DOMAIN_NAME="$1" WEB_ADMIN_PORT_VAL="$2" PUBLIC_IP="$3" local DOMAIN_NAME="$1" WEB_ADMIN_PORT_VAL="$2" PUBLIC_IP="$3"
WEB_ADMIN_PUBLIC_ACCESS_NEEDED=true # Asterisk's own web admin is never exposed publicly by this function —
# see services/security-dashboard.sh's _secdash_offer_asterisk_domain
# for the actual public-facing use of this domain instead. This only
# exists to get DOMAIN_NAME a trusted Caddy-issued TLS cert for SIP TLS,
# via a minimal keep-alive page. Cert issuance only needs Caddy to own
# the domain's site block and answer the ACME challenge there — it's
# unrelated to what the block actually serves.
#
# An earlier version of this function reverse-proxied Asterisk's own
# web admin here, gated (optionally) by Authelia, with WEB_ADMIN_AUTH_DISABLED
# flipped to 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) or a
# remote Authelia instance became unreachable/misconfigured, was left
# with Asterisk's own login OFF and nothing else gating it — extension/
# device data sitting on the public internet with no password at all.
# A remote Authelia's forward_auth also proved fragile in practice
# (DNS/routing/access-rule mismatches that are hard to diagnose from
# this box alone) for something that's only ever meant to keep a
# domain's cert alive. A Basic Auth login handled entirely inside Caddy
# itself — no external subrequest, no dependency on another box being
# correctly configured — is simpler and can't fail this way. Asterisk's
# own web admin stays reachable via the CLI only:
# docker exec -it <container> easy-asterisk
#
# Left at the caller's own default (true) here — the web admin's raw
# IP:port still needs to be reachable when there's no Caddy in the
# picture at all to front this domain instead. Only flipped to false
# once we actually confirm Caddy is fronting it (below).
if [[ -z "$DOMAIN_NAME" ]]; then if [[ -z "$DOMAIN_NAME" ]]; then
log_info "No FQDN set — web admin stays on http://${PUBLIC_IP:-localhost}:${WEB_ADMIN_PORT_VAL} (nothing for Caddy to do)." log_info "No FQDN set — nothing for Caddy to do (SIP TLS stays self-signed)."
return 0 return 0
fi fi
if [[ ! -d "$DOCKER_DIR/caddy" ]] && [[ -z "${CADDY_REMOTE_HOST:-}" ]]; then if [[ ! -d "$DOCKER_DIR/caddy" ]] && [[ -z "${CADDY_REMOTE_HOST:-}" ]]; then
log_info "Caddy not installed — web admin stays on http://${PUBLIC_IP:-localhost}:${WEB_ADMIN_PORT_VAL}, SIP TLS stays self-signed." log_info "Caddy not installed — SIP TLS stays self-signed."
return 0 return 0
fi fi
local EXTRA_BLOCK=""
if [ -d "$DOCKER_DIR/authelia" ]; then
local _use_auth=""
prompt_yn "Protect Asterisk web admin with Authelia SSO? (y/n):" "y" _use_auth
if [[ "$_use_auth" =~ ^[Yy]$ ]]; then
EXTRA_BLOCK=" import authelia"
# Disable built-in auth since Authelia handles it
sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" .env
fi
else
# No local Authelia — offer one running elsewhere (e.g. a homelab).
# There's no shared "(authelia)" Caddy snippet to import in that
# case (authelia.sh only writes one when installing locally), so
# this builds the same forward_auth block inline, targeting the
# remote instance directly instead of the local "authelia:9091"
# container reference.
local _use_remote_auth=""
prompt_yn "Protect the web admin with a remote Authelia instance (e.g. on a homelab)? (y/n):" "n" _use_remote_auth
if [[ "$_use_remote_auth" =~ ^[Yy]$ ]]; then
local _remote_authelia=""
prompt_text " Remote Authelia address — a bare host:port over a private network (e.g. a NetBird mesh IP:9091), or a full https:// URL if it's on its own public domain+TLS:" "" _remote_authelia
if [[ -n "$_remote_authelia" ]]; then
# header_up lines are required here (unlike the local
# "authelia:9091" snippet in services/authelia.sh) because
# this upstream is reached over a second Caddy hop when
# given as a scheme-qualified URL (https://auth.example.com).
# Caddy rewrites the outgoing request's Host header to that
# upstream host so the remote Caddy can route/SNI-match it —
# and without an explicit override, X-Forwarded-Host picks up
# that rewritten value instead of the original site's host.
# Confirmed live: Authelia was evaluating every request as
# if it were for auth.example.com itself (which has
# policy: bypass in access_control.rules), so every domain
# silently passed through with no 2FA prompt regardless of
# its own policy. Pinning these to the original request's
# values fixes it regardless of hop count.
#
# X-Forwarded-Host uses a literal domain, NOT the {host}
# placeholder. Confirmed live: {host} still evaluated to
# the upstream's own hostname (auth.example.com) rather
# than the original site's — Caddy appears to rewrite the
# outgoing request's Host to the upstream target before
# header_up placeholders are resolved for a scheme-
# qualified upstream, so {host} echoes back the already-
# rewritten value instead of the original client-facing
# host. Since this site block only ever serves one domain
# (DOMAIN_NAME), hardcoding it sidesteps the ambiguity
# entirely instead of depending on Caddy's internal
# header-mutation ordering.
EXTRA_BLOCK=" forward_auth ${_remote_authelia} {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
header_up X-Forwarded-Method {method}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host ${DOMAIN_NAME}
header_up X-Forwarded-Uri {uri}
}"
sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" .env
log_info "Using remote Authelia at ${_remote_authelia}."
log_info "Verify it's reachable from this box before relying on it — e.g.:"
log_info " curl -I ${_remote_authelia}"
else
log_info "No address entered — skipping Authelia protection."
fi
fi
fi
echo "" echo ""
local WANT_CADDY_PROXY="" local WANT_CADDY_PROXY=""
prompt_yn "Reverse-proxy the web admin at https://${DOMAIN_NAME}/ via Caddy? (also gets Asterisk a trusted TLS cert for SIP instead of self-signed) (y/n):" "y" WANT_CADDY_PROXY prompt_yn "Get ${DOMAIN_NAME} a trusted TLS cert via Caddy for SIP TLS? (serves a minimal keep-alive page there — not Asterisk's own web admin, which stays reachable only via 'docker exec -it <container> easy-asterisk') (y/n):" "y" WANT_CADDY_PROXY
[[ "$WANT_CADDY_PROXY" =~ ^[Yy]$ ]] || return 0 [[ "$WANT_CADDY_PROXY" =~ ^[Yy]$ ]] || return 0
# Caddy is fronting this domain now either way (locally or via a remote
# machine) — the keep-alive page doesn't reverse_proxy to anything on
# this box in either mode, so the web admin's raw port never needs to
# be reachable from the internet for this to work.
WEB_ADMIN_PUBLIC_ACCESS_NEEDED=false
local _CADDY_MODE="local" local _CADDY_MODE="local"
[[ ! -d "$DOCKER_DIR/caddy" ]] && [[ -n "${CADDY_REMOTE_HOST:-}" ]] && _CADDY_MODE="remote" [[ ! -d "$DOCKER_DIR/caddy" ]] && [[ -n "${CADDY_REMOTE_HOST:-}" ]] && _CADDY_MODE="remote"
# Asterisk runs with network_mode: host, so whatever proxies to it # Basic Auth handled entirely by Caddy — same generate/hash pattern as
# needs a way to reach the host, not "localhost" (which resolves # services/security-dashboard.sh's own independent Basic Auth layer.
# to the proxying container's own netns). A local Caddy container local BASICAUTH_BLOCK=""
# reaches the host via host.docker.internal (wired up in local _use_basicauth=""
# services/caddy.sh's compose file); a remote Caddy machine needs prompt_yn " Add a Basic Auth login on this keep-alive page? (y/n):" "y" _use_basicauth
# this box's actual public IP instead. if [[ "$_use_basicauth" =~ ^[Yy]$ ]]; then
local _PROXY_TARGET="host.docker.internal:${WEB_ADMIN_PORT_VAL}" local BA_USER="" BA_PASS="" BA_HASH=""
[[ "$_CADDY_MODE" == "remote" ]] && _PROXY_TARGET="${PUBLIC_IP}:${WEB_ADMIN_PORT_VAL}" prompt_text " Basic Auth username [admin]:" "admin" BA_USER
BA_PASS="$(generate_password 20)"
if [[ "$_CADDY_MODE" == "local" ]]; then
BA_HASH="$(docker exec caddy caddy hash-password --plaintext "$BA_PASS" 2>/dev/null)"
fi
if [ -z "$BA_HASH" ]; then
log_warning "Could not generate the Basic Auth hash — keep-alive page will be unauthenticated."
else
BASICAUTH_BLOCK=" basicauth {
${BA_USER} ${BA_HASH}
}
"
log_success "Basic Auth username: ${BA_USER}"
log_success "Basic Auth password: ${BA_PASS}"
log_warning "Save that password now — only the bcrypt hash is written to the Caddyfile, it is not stored anywhere in plaintext."
fi
fi
local _SITE_BLOCK local _SITE_BLOCK
_SITE_BLOCK="$(cat << CADDY_BLOCK _SITE_BLOCK="$(cat << CADDY_BLOCK
# Asterisk Web Admin # Asterisk domain — keep-alive page only, for the SIP TLS cert. Asterisk's
# own web admin is intentionally not served here — use the CLI instead:
# docker exec -it <container> easy-asterisk
${DOMAIN_NAME} { ${DOMAIN_NAME} {
# Auth (if any) must come before reverse_proxy — forward_auth is the ${BASICAUTH_BLOCK} respond "OK" 200
# same directive family as reverse_proxy internally, and Caddy doesn't
# reorder repeats of the same directive within a block; it runs them in
# the order they're written. With reverse_proxy first, it would handle
# and terminate every request immediately, so an auth check written
# after it would be dead code that never runs — full bypass regardless
# of what the auth server's own rules say.
${EXTRA_BLOCK}
reverse_proxy ${_PROXY_TARGET}
header { header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
@@ -1701,10 +1794,10 @@ CADDY_BLOCK
# actually works here. Try it anyway, fall back to a # actually works here. Try it anyway, fall back to a
# restart — confirmed necessary on a real deployment. # restart — confirmed necessary on a real deployment.
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
log_success "Web admin accessible at: https://${DOMAIN_NAME}" log_success "Keep-alive page live at: https://${DOMAIN_NAME}"
elif docker restart caddy &>/dev/null; then elif docker restart caddy &>/dev/null; then
log_success "Caddy restarted to apply changes (reload API is disabled by default)" log_success "Caddy restarted to apply changes (reload API is disabled by default)"
log_success "Web admin should be accessible at: https://${DOMAIN_NAME}" log_success "Keep-alive page should be live at: https://${DOMAIN_NAME}"
else else
log_warning "Reload/restart failed — check: docker logs caddy" log_warning "Reload/restart failed — check: docker logs caddy"
log_info "Manual fix: docker restart caddy" log_info "Manual fix: docker restart caddy"
@@ -1717,7 +1810,8 @@ CADDY_BLOCK
chown "$ACTUAL_USER:$ACTUAL_USER" "$_SNIPPET_DIR/asterisk.caddy" 2>/dev/null || true chown "$ACTUAL_USER:$ACTUAL_USER" "$_SNIPPET_DIR/asterisk.caddy" 2>/dev/null || true
log_success "Snippet saved: $_SNIPPET_DIR/asterisk.caddy" log_success "Snippet saved: $_SNIPPET_DIR/asterisk.caddy"
log_info "Copy to your Caddy machine: scp $_SNIPPET_DIR/asterisk.caddy caddy-host:~/caddy-snippets/" log_info "Copy to your Caddy machine: scp $_SNIPPET_DIR/asterisk.caddy caddy-host:~/caddy-snippets/"
log_info "Remote Caddy reaches this box over its public IP, so the web admin port stays open below." log_info "This is just a keep-alive page (for the cert) — the remote Caddy machine doesn't need"
log_info "to reach anything on this box for it, so no port needs to stay open here for this."
fi fi
} }
@@ -1811,6 +1905,199 @@ _asterisk_remind_non_do_firewall() {
echo " UDP ${COTURN_MIN_PORT_VAL}-${COTURN_MAX_PORT_VAL} (TURN relay)" echo " UDP ${COTURN_MIN_PORT_VAL}-${COTURN_MAX_PORT_VAL} (TURN relay)"
} }
# ── Stack health check (update mode) ────────────────────────────────────────
# "update" mode deliberately never re-asks the domain/networking/Caddy
# questions a fresh install does, on the assumption whatever's already
# configured is meant to stay that way. That assumption silently breaks for
# any of: a domain that was set but never got wired into Caddy, a Caddy
# block that exists but Asterisk's own TLS cert was never synced to match
# it (transport-tls then fails to bind — "Unable to retrieve PJSIP
# transport 'transport-tls'" in the logs, breaking every call), a baked-in
# external IP left over from before a box move (droplet revert, IP
# reassignment), or any of the services that chain off Asterisk (Security
# Dashboard, sms-inbound, ntfy) having the exact same "domain set, nothing
# actually serving it" gap of their own — none of which "update" would
# ever notice or mention on its own. Confirmed live, all of them, across a
# single droplet revert.
#
# Runs every "update", unconditionally — the CHECKING is never opt-in, so a
# gap is never missed just because nobody thought to ask. Each FIX is
# opt-in and named explicitly as a change when offered, since it's real
# config being written (a Caddy block, a synced cert, a rewritten IP) —
# never silent, unlike the rest of "update" mode's core promise of
# touching nothing. Nothing is written unless a fix is explicitly accepted.
_asterisk_run_stack_health_check() {
local EA_DIR="$1" CONTAINER="$2"
local ISSUES_FOUND=0
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " STACK HEALTH CHECK"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
local _DOMAIN _PORT
_DOMAIN="$(grep -E '^DOMAIN_NAME=' "$EA_DIR/.env" 2>/dev/null | cut -d= -f2-)"
_PORT="$(grep -E '^WEB_ADMIN_PORT=' "$EA_DIR/.env" 2>/dev/null | cut -d= -f2-)"
# ── 1. External IP baked into pjsip.conf ────────────────────────────────
local _BAKED_IP
_BAKED_IP="$(grep -m1 '^external_signaling_address=' "$EA_DIR/config/asterisk/pjsip.conf" 2>/dev/null | cut -d= -f2)"
if [[ -n "$_BAKED_IP" ]]; then
local _LIVE_IP
_LIVE_IP="$(_asterisk_current_public_ip)"
if [[ -n "$_LIVE_IP" ]] && [[ "$_BAKED_IP" != "$_LIVE_IP" ]]; then
ISSUES_FOUND=$((ISSUES_FOUND + 1))
log_warning "✗ Public IP: pjsip.conf has ${_BAKED_IP} baked in — this box is now ${_LIVE_IP}."
log_warning " Every call's media negotiation is broken until this is fixed."
local _FIX_IP=""
prompt_yn " Fix it now? This rewrites external_media_address/external_signaling_address to ${_LIVE_IP} in every config file that has the old IP, then restarts Asterisk — drops any call in progress. (y/n):" "y" _FIX_IP
if [[ "$_FIX_IP" =~ ^[Yy]$ ]]; then
local _ESC_OLD="${_BAKED_IP//./\\.}"
grep -rlF "$_BAKED_IP" "$EA_DIR/config" "$EA_DIR/.env" 2>/dev/null | while read -r _f; do
sed -i "s/$_ESC_OLD/$_LIVE_IP/g" "$_f"
done
(cd "$EA_DIR" && docker compose restart) \
&& log_success " Fixed — pjsip.conf now points at ${_LIVE_IP}, Asterisk restarted." \
|| log_warning " Restart failed — check: docker compose -f $EA_DIR/docker-compose.yml logs"
fi
else
log_success "✓ Public IP matches what's baked into pjsip.conf (${_BAKED_IP})."
fi
fi
# ── 2. Asterisk's own web-admin/SIP domain: Caddy block + TLS cert ──────
if [[ -z "$_DOMAIN" ]]; then
log_info "— No DOMAIN_NAME set (LAN-only / self-signed) — nothing to check here."
elif [[ ! -d "$DOCKER_DIR/caddy" ]]; then
log_info "${_DOMAIN} is set, but no local Caddy is installed here to check."
else
if grep -q "^${_DOMAIN}" "$DOCKER_DIR/caddy/Caddyfile" 2>/dev/null; then
log_success "${_DOMAIN}: Caddy site block present."
else
ISSUES_FOUND=$((ISSUES_FOUND + 1))
log_warning "${_DOMAIN}: DOMAIN_NAME is set, but Caddy has no site block for it — nothing is serving it."
local _FIX_CADDY=""
prompt_yn " Add a Caddy site block for ${_DOMAIN} now? (y/n):" "y" _FIX_CADDY
if [[ "$_FIX_CADDY" =~ ^[Yy]$ ]]; then
_asterisk_configure_caddy_public "$_DOMAIN" "${_PORT:-8081}" "$(_asterisk_current_public_ip)"
fi
fi
local _CERT_OK=false
if docker exec "$CONTAINER" sh -c "openssl x509 -in /etc/asterisk/certs/server.crt -noout -ext subjectAltName 2>/dev/null | grep -q \"DNS:${_DOMAIN}\"" 2>/dev/null; then
_CERT_OK=true
fi
if [[ "$_CERT_OK" == true ]]; then
log_success "${_DOMAIN}: TLS certificate matches (transport-tls can bind)."
else
ISSUES_FOUND=$((ISSUES_FOUND + 1))
log_warning "${_DOMAIN}: no valid TLS certificate for this domain in the container —"
log_warning " the transport-tls PJSIP transport will fail to bind, breaking every call"
log_warning " (\"Unable to retrieve PJSIP transport 'transport-tls'\" in the logs)."
local _FIX_CERT=""
prompt_yn " Sync a certificate from Caddy and restart Asterisk now? (y/n):" "y" _FIX_CERT
if [[ "$_FIX_CERT" =~ ^[Yy]$ ]]; then
if docker exec "$CONTAINER" /usr/local/bin/easy-asterisk --sync-caddy-cert 2>&1 | tail -5; then
log_success " Cert sync ran — verify: docker exec $CONTAINER openssl x509 -in /etc/asterisk/certs/server.crt -noout -ext subjectAltName"
else
log_warning " Cert sync failed — Caddy may not have a certificate for ${_DOMAIN} yet"
log_warning " (check: docker logs caddy), or this container predates the"
log_warning " --sync-caddy-cert flag — re-run update once more first."
fi
fi
fi
fi
# ── 3. Chained services: Security Dashboard, sms-inbound, ntfy ──────────
# Each of these has the exact same "domain set (or fixed), Caddy never
# wired" gap Asterisk itself just had — none of them persist enough
# state to fix it without re-asking for a domain, so caddy_domain_for_
# upstream (lib/common.sh) checks the Caddyfile directly instead, and a
# found gap points at that service's own reinstall rather than trying
# to script a fix here for config this file doesn't own.
#
# This box's local Caddyfile is the only thing checkable from here —
# any of these three can instead be fronted by a Caddy (and Authelia)
# on a completely different box, the same remote-Caddy pattern
# sms-inbound.sh and ntfy.sh's own installers already support (see
# CADDY_MODE/CADDY_REMOTE_HOST in the site config). "Not found in the
# local Caddyfile" only COUNTS as an issue when the site is actually
# configured for local Caddy — the same resolution those installers use.
# In remote (or no-Caddy) mode it's expected, not broken: reported
# informationally, with no fix offered, since guessing wrong here would
# add a redundant/conflicting local block for something deliberately
# fronted elsewhere.
local _SITE_CADDY_MODE="${CADDY_MODE:-none}"
[ "$_SITE_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _SITE_CADDY_MODE="local"
[ "$_SITE_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _SITE_CADDY_MODE="remote"
if declare -F caddy_domain_for_upstream >/dev/null 2>&1; then
if [[ -f /opt/security-dashboard/app.py ]]; then
local _SD_DOMAIN
_SD_DOMAIN="$(caddy_domain_for_upstream "host.docker.internal:8092")"
if [[ -n "$_SD_DOMAIN" ]]; then
log_success "✓ Security Dashboard: Caddy serving it at ${_SD_DOMAIN}."
elif [[ "$_SITE_CADDY_MODE" != "local" ]]; then
log_info "— Security Dashboard: no site block in this box's local Caddyfile (site is in ${_SITE_CADDY_MODE} Caddy mode — likely fronted by a Caddy/Authelia on a different box; not checked here)."
else
ISSUES_FOUND=$((ISSUES_FOUND + 1))
log_warning "✗ Security Dashboard is installed, but Caddy has no site block for it."
local _FIX_SD=""
prompt_yn " Configure Caddy for the Security Dashboard now? (y/n):" "y" _FIX_SD
if [[ "$_FIX_SD" =~ ^[Yy]$ ]] && declare -F _secdash_configure_caddy >/dev/null 2>&1; then
_secdash_configure_caddy 8092
elif [[ "$_FIX_SD" =~ ^[Yy]$ ]]; then
log_warning " services/security-dashboard.sh isn't loaded in this run — re-run it directly: sudo ./setup.sh security-dashboard"
fi
fi
fi
if [[ -f /opt/sms-inbound/settings.env ]]; then
local SMS_RELAY_DOMAIN="" SMS_RELAY_PORT="" SMS_FORWARD_URL=""
# shellcheck disable=SC1091
source /opt/sms-inbound/settings.env
if [[ -z "$SMS_RELAY_DOMAIN" || "$SMS_FORWARD_URL" == *"<your-domain>"* ]]; then
ISSUES_FOUND=$((ISSUES_FOUND + 1))
log_warning "✗ sms-inbound is installed, but has no real webhook domain set — SMS delivery can't work."
log_warning " Re-run 'sudo ./setup.sh sms-inbound' and choose \"f) Full reinstall\" to be asked for it (needs DNS pointed here first)."
elif [[ -n "$(caddy_domain_for_upstream "host.docker.internal:${SMS_RELAY_PORT}")" ]]; then
log_success "✓ sms-inbound: Caddy serving the webhook at ${SMS_RELAY_DOMAIN}."
elif [[ "$_SITE_CADDY_MODE" != "local" ]]; then
log_info "— sms-inbound: no site block in this box's local Caddyfile (site is in ${_SITE_CADDY_MODE} Caddy mode — likely fronted by a Caddy/Authelia on a different box; not checked here)."
else
ISSUES_FOUND=$((ISSUES_FOUND + 1))
log_warning "✗ sms-inbound has a domain set (${SMS_RELAY_DOMAIN}), but Caddy has no site block for it."
log_warning " Re-run 'sudo ./setup.sh sms-inbound' and choose \"f) Full reinstall\" to fix it (re-enters the same domain, re-adds the Caddy block)."
fi
fi
local _ntfy_dir
for _ntfy_dir in "$DOCKER_DIR"/ntfy "$DOCKER_DIR"/ntfy-*; do
[[ -d "$_ntfy_dir" ]] || continue
local _ntfy_container
_ntfy_container="$(basename "$_ntfy_dir")"
local _NTFY_DOMAIN
_NTFY_DOMAIN="$(caddy_domain_for_upstream "${_ntfy_container}:80")"
if [[ -n "$_NTFY_DOMAIN" ]]; then
log_success "✓ ntfy (${_ntfy_container}): Caddy serving it at ${_NTFY_DOMAIN}."
elif [[ "$_SITE_CADDY_MODE" != "local" ]]; then
log_info "— ntfy (${_ntfy_container}): no site block in this box's local Caddyfile (site is in ${_SITE_CADDY_MODE} Caddy mode — likely fronted by a Caddy/Authelia on a different box; not checked here)."
else
ISSUES_FOUND=$((ISSUES_FOUND + 1))
log_warning "✗ ntfy (${_ntfy_container}) is installed, but Caddy has no site block for it."
log_warning " Re-run 'sudo ./setup.sh ntfy' and choose \"f) Full reinstall\" to fix it — that's the only mode that re-asks the domain."
fi
done
fi
echo ""
if [[ "$ISSUES_FOUND" -eq 0 ]]; then
log_success "Stack health check: everything checked is fully wired."
else
log_warning "Stack health check: $ISSUES_FOUND issue(s) found (see above)."
fi
}
# ── Shared: README ───────────────────────────────────────────────────────── # ── Shared: README ─────────────────────────────────────────────────────────
# One document with a droplet-only section appended in public-cloud mode, so # One document with a droplet-only section appended in public-cloud mode, so
# the two deployment shapes can't document themselves differently by accident. # the two deployment shapes can't document themselves differently by accident.
@@ -2163,6 +2450,10 @@ install_asterisk() {
echo "[DRY-RUN] Would offer to also set up the Security Dashboard and a PSTN trunk in this" echo "[DRY-RUN] Would offer to also set up the Security Dashboard and a PSTN trunk in this"
echo "[DRY-RUN] same run (calling services/security-dashboard.sh / services/pstn-trunk.sh" echo "[DRY-RUN] same run (calling services/security-dashboard.sh / services/pstn-trunk.sh"
echo "[DRY-RUN] directly — both stay independently invocable via their own service name too)" echo "[DRY-RUN] directly — both stay independently invocable via their own service name too)"
echo "[DRY-RUN] Update mode would run a stack health check: baked-in public IP vs. this"
echo "[DRY-RUN] box's actual one, Asterisk's own domain (Caddy block + TLS cert), and"
echo "[DRY-RUN] whether the Security Dashboard/sms-inbound/ntfy (if installed) actually"
echo "[DRY-RUN] have Caddy wired up — reports anything unwired and offers to fix it"
return 0 return 0
fi fi
@@ -2211,6 +2502,7 @@ install_asterisk() {
_asterisk_write_voicemail_conf "$EA_DIR/config/asterisk/voicemail.conf" _asterisk_write_voicemail_conf "$EA_DIR/config/asterisk/voicemail.conf"
_asterisk_ensure_live_voicemail_include "$EA_DIR" "$CONTAINER" _asterisk_ensure_live_voicemail_include "$EA_DIR" "$CONTAINER"
_asterisk_patch_keepalive_vendor_files "$EA_DIR" _asterisk_patch_keepalive_vendor_files "$EA_DIR"
_asterisk_patch_cert_sync_cli "$EA_DIR"
ensure_docker_dir_ownership "$EA_DIR/config/asterisk" ensure_docker_dir_ownership "$EA_DIR/config/asterisk"
chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" "$EA_DIR/config/asterisk/voicemail-dialplan.conf" chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" "$EA_DIR/config/asterisk/voicemail-dialplan.conf"
@@ -2241,8 +2533,11 @@ install_asterisk() {
local _EXISTING_DOMAIN _EXISTING_PORT local _EXISTING_DOMAIN _EXISTING_PORT
_EXISTING_DOMAIN="$(grep -E '^DOMAIN_NAME=' .env | cut -d= -f2-)" _EXISTING_DOMAIN="$(grep -E '^DOMAIN_NAME=' .env | cut -d= -f2-)"
_EXISTING_PORT="$(grep -E '^WEB_ADMIN_PORT=' .env | cut -d= -f2-)" _EXISTING_PORT="$(grep -E '^WEB_ADMIN_PORT=' .env | cut -d= -f2-)"
log_success "Existing .env and firewall rules were left untouched."
_asterisk_run_stack_health_check "$EA_DIR" "$CONTAINER"
echo "" echo ""
log_success "Existing .env, firewall rules, and Caddy/Authelia config were left untouched."
if [[ -n "$_EXISTING_DOMAIN" ]]; then if [[ -n "$_EXISTING_DOMAIN" ]]; then
echo " Web admin: https://${_EXISTING_DOMAIN}/" echo " Web admin: https://${_EXISTING_DOMAIN}/"
else else
@@ -2438,6 +2733,7 @@ install_asterisk() {
# WireGuard/Tailscale) on a subnet this box isn't directly attached to." # WireGuard/Tailscale) on a subnet this box isn't directly attached to."
fi fi
backup_if_exists .env
cat > .env << ENV cat > .env << ENV
# ── Domain ──────────────────────────────────────────────────── # ── Domain ────────────────────────────────────────────────────
# ${_domain_comment} # ${_domain_comment}
+63
View File
@@ -180,6 +180,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -198,6 +203,59 @@ fi
register_service audiobookshelf media "Audiobook & podcast server (Audiobookshelf)" 13378 register_service audiobookshelf media "Audiobook & podcast server (Audiobookshelf)" 13378
# Offers to register Audiobookshelf as an Authelia OIDC client and prints
# exactly what to paste into its own settings — checked against
# audiobookshelf.org's own OIDC docs directly: config lives entirely in
# Settings -> Authentication in the app's UI, no env var or config API to
# automate the app side with (unlike Mealie/ActualBudget/Immich), so this
# only automates the Authelia half. Its own Authelia integration doc
# (authelia.com) requires PKCE — the fifth arg to
# _authelia_provision_oidc_client below.
_audiobookshelf_offer_authelia_oidc() {
declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0
[ -d "$DOCKER_DIR/authelia" ] || return 0
echo ""
local USE_SSO=""
prompt_yn " Add \"Sign in with Authelia\" (OpenID Connect) to Audiobookshelf? (y/n):" "n" USE_SSO
[[ "$USE_SSO" =~ ^[Yy]$ ]] || return 0
local APP_DOMAIN
APP_DOMAIN="$(_authelia_pick_domain "Domain Audiobookshelf is reachable at (number or domain)")"
if [ -z "$APP_DOMAIN" ]; then
log_warning "No domain entered — skipping SSO setup."
return 0
fi
local _2fa="" AUTH_POLICY="two_factor"
prompt_yn " Require two-factor for Audiobookshelf logins via Authelia too? (y/n):" "y" _2fa
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
if ! _authelia_provision_oidc_client "Audiobookshelf" "audiobookshelf" "$AUTH_POLICY" "y" "y" "" \
"https://${APP_DOMAIN}/auth/openid/callback" "https://${APP_DOMAIN}/auth/openid/mobile-redirect" "audiobookshelf://oauth"; then
log_warning "Couldn't register Audiobookshelf as an OIDC client in Authelia — skipping SSO setup."
return 0
fi
echo ""
echo " Audiobookshelf -> Settings -> Authentication -> enable OpenID Connect"
echo " Authentication, then fill in (it wants individual endpoints, not a"
echo " discovery URL):"
echo " Issuer URL: ${OIDC_AUTHELIA_PORTAL_URL}"
echo " Authorize URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/authorization"
echo " Token URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/token"
echo " Userinfo URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/userinfo"
echo " JWKS URL: ${OIDC_AUTHELIA_PORTAL_URL}/jwks.json"
echo " Client ID: audiobookshelf"
echo " Client Secret: $OIDC_CLIENT_SECRET_PLAIN"
echo " Signing Algorithm: RS256"
echo " Allowed Mobile Redirect URIs: audiobookshelf://oauth"
echo ""
log_warning "The Client Secret above is shown once — save it now."
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "audiobookshelf" "$APP_DOMAIN"
}
install_audiobookshelf() { install_audiobookshelf() {
require_docker || return 1 require_docker || return 1
@@ -261,6 +319,7 @@ install_audiobookshelf() {
( cd "$ABS_DIR" && docker compose pull && docker compose up -d ) \ ( cd "$ABS_DIR" && docker compose pull && docker compose up -d ) \
&& log_success "Audiobookshelf image refreshed" \ && log_success "Audiobookshelf image refreshed" \
|| log_warning "Refresh failed — check: docker compose -f $ABS_DIR/docker-compose.yml logs" || log_warning "Refresh failed — check: docker compose -f $ABS_DIR/docker-compose.yml logs"
_audiobookshelf_offer_authelia_oidc
return 0 return 0
;; ;;
cancel) cancel)
@@ -325,6 +384,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << ABS_COMPOSE cat > docker-compose.yml << ABS_COMPOSE
name: $CONTAINER name: $CONTAINER
@@ -346,6 +406,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
ABS_COMPOSE ABS_COMPOSE
backup_if_exists .env
cat > .env << ABS_ENV cat > .env << ABS_ENV
AUDIOBOOKS_PATH=$AUDIOBOOKS_PATH AUDIOBOOKS_PATH=$AUDIOBOOKS_PATH
PODCASTS_PATH=./podcasts PODCASTS_PATH=./podcasts
@@ -358,6 +419,8 @@ ABS_ENV
configure_caddy_for_service "Audiobookshelf${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:80" "audiobooks${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" configure_caddy_for_service "Audiobookshelf${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:80" "audiobooks${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
_audiobookshelf_offer_authelia_oidc
write_readme "$ABS_DIR" << MD write_readme "$ABS_DIR" << MD
# Audiobookshelf${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} # Audiobookshelf${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
+1775 -124
View File
File diff suppressed because it is too large Load Diff
+119
View File
@@ -120,6 +120,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
local _companion; _companion="$(dirname "${BASH_SOURCE[0]}")/beszel.md" local _companion; _companion="$(dirname "${BASH_SOURCE[0]}")/beszel.md"
[ -f "$_companion" ] && cat "$_companion" >> "$_dir/README.md" [ -f "$_companion" ] && cat "$_companion" >> "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -251,6 +256,112 @@ _beszel_configure_agent() {
|| log_warning "Agent failed to start — check: docker compose -f $dir/docker-compose.yml logs beszel-agent" || log_warning "Agent failed to start — check: docker compose -f $dir/docker-compose.yml logs beszel-agent"
} }
# Registers Beszel as an Authelia OIDC client and prints exactly what to
# paste into the hub's own settings. Checked against beszel.dev's own OAuth
# docs directly: Beszel is PocketBase-based, and its OAuth2 provider config
# is a PocketBase admin-UI setting (Settings -> Auth providers), not
# something exposed by any documented API or env var — so, like
# Audiobookshelf, this only automates the Authelia half. Beszel's own
# Authelia integration doc (authelia.com) requires PKCE.
#
# Args: DIR (the .env holding DISABLE_PASSWORD_AUTH/USER_CREATION lives there)
_beszel_offer_authelia_oidc() {
local dir="$1"
declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0
[ -d "$DOCKER_DIR/authelia" ] || return 0
echo ""
local USE_SSO=""
prompt_yn " Add \"Sign in with Authelia\" (OpenID Connect) to Beszel? (y/n):" "n" USE_SSO
[[ "$USE_SSO" =~ ^[Yy]$ ]] || return 0
local APP_DOMAIN
APP_DOMAIN="$(_authelia_pick_domain "Domain Beszel is reachable at (number or domain)")"
if [ -z "$APP_DOMAIN" ]; then
log_warning "No domain entered — skipping SSO setup."
return 0
fi
local _2fa="" AUTH_POLICY="two_factor"
prompt_yn " Require two-factor for Beszel logins via Authelia too? (y/n):" "y" _2fa
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
if ! _authelia_provision_oidc_client "Beszel" "beszel" "$AUTH_POLICY" "y" "y" "" \
"https://${APP_DOMAIN}/api/oauth2-redirect"; then
log_warning "Couldn't register Beszel as an OIDC client in Authelia — skipping SSO setup."
return 0
fi
echo ""
echo " This lives in PocketBase's own admin panel underneath the hub, not the"
echo " hub's own Settings page — checked against beszel.dev's OAuth guide"
echo " directly, exact steps:"
echo " 1) Go to https://<your-beszel-domain>/_/#/settings and toggle OFF"
echo " \"Hide collection create and edit controls\""
echo " 2) Go to Collections, edit the \"users\" collection"
echo " 3) Options tab -> enable OAuth2 -> Add provider, fill in:"
echo " Client ID: beszel"
echo " Client Secret: $OIDC_CLIENT_SECRET_PLAIN"
echo " Auth URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/authorization"
echo " Token URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/token"
echo " User Info URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/userinfo"
echo " 4) Save, then toggle \"Hide collection create and edit controls\" back ON"
echo " at /_/#/settings — leaving it off is its own exposure once you're done"
echo ""
log_warning "The Client Secret above is shown once — save it now."
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "beszel" "$APP_DOMAIN"
echo ""
log_info "Paste those values into Beszel's Settings -> Auth providers -> OpenID"
log_info "Connect page now, then log out and click through the Authelia login"
log_info "button to confirm it actually works — BEFORE going any further here."
echo ""
log_warning "The next step can disable Beszel's password login entirely. Confirmed"
log_warning "live: saying yes here before actually testing the button leaves NEITHER"
log_warning "login path working — the password form is gone, and the OAuth provider"
log_warning "was never actually finished on Beszel's side, so its button never"
log_warning "appears either. Re-run 'sudo ./setup.sh beszel' (choose update) any time"
log_warning "later to come back to this once you've verified the button works."
}
# Split out from _beszel_offer_authelia_oidc so it can also be re-reached on
# its own via a later "update" rerun, once the admin has actually gone and
# tested the Authelia login button — see that function's own warning for
# why this can't be offered in the same breath as printing the paste-in
# values. DISABLE_PASSWORD_AUTH/USER_CREATION are real, documented env vars
# (beszel.dev's own OAuth guide).
_beszel_offer_disable_password_auth() {
local dir="$1"
[ -f "$dir/.env" ] || return 0
grep -qF "client_id: 'beszel'" "$DOCKER_DIR/authelia/config/configuration.yml" 2>/dev/null || return 0
grep -q '^DISABLE_PASSWORD_AUTH=true' "$dir/.env" 2>/dev/null && return 0
echo ""
local _tested=""
prompt_yn " Have you ALREADY logged into Beszel successfully using the Authelia button (not just pasted the values)? (y/n):" "n" _tested
if [[ ! "$_tested" =~ ^[Yy]$ ]]; then
log_info "Skipped. Test the Authelia login button first, then re-run 'sudo ./setup.sh beszel' (choose update) to come back to this."
return 0
fi
local _disable_local=""
prompt_yn " Disable Beszel's own password login now, so Authelia is the only way in? (y/n):" "n" _disable_local
[[ "$_disable_local" =~ ^[Yy]$ ]] || return 0
local _auto_register=""
prompt_yn " Auto-create Beszel accounts for new Authelia logins? (y/n):" "n" _auto_register
sed -i '/^DISABLE_PASSWORD_AUTH=/d; /^USER_CREATION=/d' "$dir/.env"
{
echo "DISABLE_PASSWORD_AUTH=true"
[[ "$_auto_register" =~ ^[Yy]$ ]] && echo "USER_CREATION=true"
} >> "$dir/.env"
chown "$ACTUAL_USER:$ACTUAL_USER" "$dir/.env" 2>/dev/null || true
( cd "$dir" && docker compose up -d beszel ) \
&& log_success "Beszel's own password login is now disabled — Authelia is the only way in." \
|| log_warning "Restart failed — check: docker compose -f $dir/docker-compose.yml logs beszel"
}
install_beszel() { install_beszel() {
require_docker || return 1 require_docker || return 1
log_info "Installing Beszel..." log_info "Installing Beszel..."
@@ -286,6 +397,8 @@ install_beszel() {
prompt_yn " The agent was never connected — set it up now? (y/n):" "y" FINISH_AGENT prompt_yn " The agent was never connected — set it up now? (y/n):" "y" FINISH_AGENT
[[ "$FINISH_AGENT" =~ ^[Yy]$ ]] && _beszel_configure_agent "$DIR" "http://localhost:${_WP} (or its Caddy domain, once configured)" [[ "$FINISH_AGENT" =~ ^[Yy]$ ]] && _beszel_configure_agent "$DIR" "http://localhost:${_WP} (or its Caddy domain, once configured)"
fi fi
_beszel_offer_authelia_oidc "$DIR"
_beszel_offer_disable_password_auth "$DIR"
return 0 return 0
;; ;;
cancel) cancel)
@@ -350,6 +463,7 @@ networks:
# profile recognizes. security_opt: apparmor:unconfined below is # profile recognizes. security_opt: apparmor:unconfined below is
# Beszel's own documented fix (beszel.dev/guide/systemd#apparmor-error) # Beszel's own documented fix (beszel.dev/guide/systemd#apparmor-error)
# — confirmed live, this exact error on a real box. # — confirmed live, this exact error on a real box.
backup_if_exists docker-compose.yml
cat > docker-compose.yml << BESZEL_COMPOSE cat > docker-compose.yml << BESZEL_COMPOSE
name: beszel name: beszel
@@ -396,6 +510,7 @@ BESZEL_COMPOSE
# README). Not threaded through automatically here because Caddy setup # README). Not threaded through automatically here because Caddy setup
# (below) happens after this file is written, same ordering every # (below) happens after this file is written, same ordering every
# other service in this repo uses for its own Caddy prompt. # other service in this repo uses for its own Caddy prompt.
backup_if_exists .env
cat > .env << BESZEL_ENV cat > .env << BESZEL_ENV
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)} TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
@@ -423,6 +538,8 @@ BESZEL_ENV
_beszel_configure_agent "$DIR" "http://localhost:${WEB_PORT} (or its Caddy domain, once configured)" _beszel_configure_agent "$DIR" "http://localhost:${WEB_PORT} (or its Caddy domain, once configured)"
_beszel_offer_authelia_oidc "$DIR"
write_readme "$DIR" << 'BESZEL_README' write_readme "$DIR" << 'BESZEL_README'
# Beszel — lightweight server + Docker monitoring # Beszel — lightweight server + Docker monitoring
@@ -568,6 +685,7 @@ install_beszel-agent() {
# and there's no caddy_net to conditionally join since this box never # and there's no caddy_net to conditionally join since this box never
# runs a web UI of its own. See that function's own comment for why the # runs a web UI of its own. See that function's own comment for why the
# systemd/dbus/sensor mounts below matter (Services/Temp columns). # systemd/dbus/sensor mounts below matter (Services/Temp columns).
backup_if_exists docker-compose.yml
cat > docker-compose.yml << AGENT_COMPOSE cat > docker-compose.yml << AGENT_COMPOSE
name: beszel-agent name: beszel-agent
@@ -593,6 +711,7 @@ services:
- /sys/class/thermal:/sys/class/thermal:ro - /sys/class/thermal:/sys/class/thermal:ro
AGENT_COMPOSE AGENT_COMPOSE
backup_if_exists .env
cat > .env << AGENT_ENV cat > .env << AGENT_ENV
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)} TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
HUB_URL=$HUB_URL HUB_URL=$HUB_URL
+6
View File
@@ -192,6 +192,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -279,6 +284,7 @@ install_caddy() {
cd "$CADDY_DIR" || return 1 cd "$CADDY_DIR" || return 1
backup_if_exists docker-compose.yml
cat > docker-compose.yml << 'CADDY_COMPOSE' cat > docker-compose.yml << 'CADDY_COMPOSE'
name: caddy name: caddy
+7
View File
@@ -177,6 +177,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -242,6 +247,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << CW_COMPOSE cat > docker-compose.yml << CW_COMPOSE
name: calibre-web name: calibre-web
@@ -264,6 +270,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
CW_COMPOSE CW_COMPOSE
backup_if_exists .env
cat > .env << CW_ENV cat > .env << CW_ENV
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
CW_ENV CW_ENV
+7
View File
@@ -177,6 +177,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -243,6 +248,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << CD_COMPOSE cat > docker-compose.yml << CD_COMPOSE
name: changedetection name: changedetection
@@ -272,6 +278,7 @@ ${_CADDY_NET_BLOCK} depends_on:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
CD_COMPOSE CD_COMPOSE
backup_if_exists .env
cat > .env << CD_ENV cat > .env << CD_ENV
# Changedetection.io environment — edit before starting if needed # Changedetection.io environment — edit before starting if needed
BASE_URL=https://changes.${SITE_DOMAIN} BASE_URL=https://changes.${SITE_DOMAIN}
+155
View File
@@ -0,0 +1,155 @@
#!/bin/bash
# services/claude-cli.sh — Claude Code CLI: dual-account setup, model/effort
# defaults, and a shared global CLAUDE.md.
#
# Non-Docker (see CLAUDE.md's "Non-Docker services" section). Installs the
# official Claude Code CLI if missing, then wires up:
# - two independent account config directories (work/personal), each its
# own CLAUDE_CONFIG_DIR behind a shell alias, so `claude-work` and
# `claude-personal` are two fully separate logins on one machine
# - one shared, imported global CLAUDE.md (durable personal conventions —
# modular/reuse code, numbered CLI menus with 0=always-exit, verify web
# UI changes with Playwright) that both accounts pull in via `@import`,
# so there's exactly one copy to edit, not two that can drift
# - settings.json defaults applied to both accounts: model pinned to
# claude-sonnet-5, effort level medium, and ENABLE_PROMPT_CACHING_1H=1
# (keeps the 1h prompt-cache lifetime even after usage credits kick in,
# instead of dropping to 5 minutes — see services/ai-stack.md's hybrid
# workflow section for why this pairs with a local-GPU + Claude Code split)
#
# The Anthropic login itself (browser OAuth) can't be scripted — this only
# prepares the directories/aliases/config. Run `claude-work` and
# `claude-personal` once each afterward to actually log each one in.
# Part of the modular post-install system (sourced by setup.sh).
register_service claude-cli extras "Claude Code CLI — dual-account setup (work/personal), model/effort defaults, shared global CLAUDE.md"
install_claude-cli() {
local WORK_DIR="$ACTUAL_HOME/.claude-work"
local PERSONAL_DIR="$ACTUAL_HOME/.claude-personal"
local SHARED_DIR="$ACTUAL_HOME/.claude-shared"
local SHARED_CLAUDE_MD="$SHARED_DIR/CLAUDE.md"
local BASHRC="$ACTUAL_HOME/.bashrc"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would install the Claude Code CLI (official installer) if missing"
echo "[DRY-RUN] Would create $WORK_DIR and $PERSONAL_DIR config dirs"
echo "[DRY-RUN] Would write $SHARED_CLAUDE_MD (shared conventions) and import it from each account's CLAUDE.md"
echo "[DRY-RUN] Would write settings.json (model=claude-sonnet-5, effortLevel=medium, ENABLE_PROMPT_CACHING_1H=1) into each account dir"
echo "[DRY-RUN] Would add claude-work/claude-personal aliases to $BASHRC (idempotent)"
return 0
fi
if [ -f "$SHARED_CLAUDE_MD" ]; then
local MODE=""
prompt_reinstall_mode MODE
case "$MODE" in
update)
log_info "Refreshing shared CLAUDE.md and settings.json only — account dirs/credentials untouched."
_claude_cli_write_shared_claude_md "$SHARED_CLAUDE_MD"
_claude_cli_write_settings "$WORK_DIR/settings.json"
_claude_cli_write_settings "$PERSONAL_DIR/settings.json"
ensure_docker_dir_ownership "$SHARED_DIR" "$WORK_DIR" "$PERSONAL_DIR"
log_success "claude-cli config refreshed"
return 0
;;
cancel)
log_info "Leaving the existing claude-cli setup as-is."
return 0
;;
fresh) ;; # fall through to the full setup below
esac
fi
# ── Install the CLI itself ──────────────────────────────────────────────
if ! command -v claude >/dev/null 2>&1; then
log_info "Installing Claude Code CLI..."
if curl -fsSL https://claude.ai/install.sh | bash; then
log_success "Claude Code CLI installed"
else
log_error "Claude Code CLI install failed — see https://code.claude.com/docs/en/setup"
return 1
fi
else
log_info "Claude Code CLI already installed ($(command -v claude))"
fi
# ── Account config dirs + shared conventions ────────────────────────────
mkdir -p "$WORK_DIR" "$PERSONAL_DIR" "$SHARED_DIR"
_claude_cli_write_shared_claude_md "$SHARED_CLAUDE_MD"
local _dir
for _dir in "$WORK_DIR" "$PERSONAL_DIR"; do
# @import pulls the shared file in at session start (see Claude
# Code's memory docs) — one canonical copy, not two that can drift.
[ -f "$_dir/CLAUDE.md" ] || printf '@%s\n' "$SHARED_CLAUDE_MD" > "$_dir/CLAUDE.md"
_claude_cli_write_settings "$_dir/settings.json"
done
# ── Shell aliases — idempotent, same append-once pattern base.sh uses
# for tab completion (grep-before-append, chown after) ──────────────────
if [ -f "$BASHRC" ] && ! grep -qF "CLAUDE_CONFIG_DIR=$WORK_DIR" "$BASHRC" 2>/dev/null; then
{
echo ""
echo "# ubuntu-post-install: claude-cli dual-account aliases"
echo "alias claude-work='CLAUDE_CONFIG_DIR=$WORK_DIR claude'"
echo "alias claude-personal='CLAUDE_CONFIG_DIR=$PERSONAL_DIR claude'"
} >> "$BASHRC"
chown "$ACTUAL_USER:$ACTUAL_USER" "$BASHRC" 2>/dev/null || true
log_success "Added claude-work / claude-personal aliases to $BASHRC (new shells, or: source $BASHRC)"
fi
ensure_docker_dir_ownership "$SHARED_DIR" "$WORK_DIR" "$PERSONAL_DIR"
echo ""
log_warning "Login still needs a one-time browser step per account — this only prepared the plumbing:"
echo " claude-work # first run: browser OAuth login for your work account"
echo " claude-personal # first run: browser OAuth login for your personal account"
echo ""
echo " Shared conventions : $SHARED_CLAUDE_MD (edit once, both accounts see it)"
echo " Work config : $WORK_DIR"
echo " Personal config : $PERSONAL_DIR"
echo ""
}
# Writes/refreshes the three keys this service owns via jq (preserves any
# other hand-added settings, e.g. permissions); falls back to a fresh file
# if jq is missing (base.sh installs it, but this service can run standalone)
# or the existing file isn't valid JSON.
_claude_cli_write_settings() {
local dest="$1"
local patch='{"model":"claude-sonnet-5","effortLevel":"medium","env":{"ENABLE_PROMPT_CACHING_1H":"1"}}'
if [ -f "$dest" ] && command -v jq >/dev/null 2>&1; then
local merged
merged="$(jq -s '.[0] * .[1]' "$dest" <(echo "$patch") 2>/dev/null)" \
&& [ -n "$merged" ] \
&& printf '%s\n' "$merged" > "$dest" \
&& return 0
log_warning "$dest wasn't valid JSON — leaving it untouched. Merge manually: $patch"
return 0
fi
[ -f "$dest" ] && return 0
echo "$patch" | (command -v jq >/dev/null 2>&1 && jq . || cat) > "$dest"
}
_claude_cli_write_shared_claude_md() {
cat > "$1" << 'EOF'
# Personal conventions (all projects, both accounts)
## Code reuse
Write shared logic once, in one place. Before adding a new function, check
whether an existing one already does it — extend/parameterize rather than
duplicate.
## CLI menus
Every interactive menu is numbered. `0` is always "exit" / "back" — never
reused for another action, and always present, even on a submenu.
## Verifying web UI changes
After any frontend change, drive it with Playwright before calling it done —
navigate the real page, exercise the changed flow, screenshot if the result
is visual. Don't declare a UI task complete from reading the code alone.
EOF
}
+7
View File
@@ -174,6 +174,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -212,6 +217,7 @@ install_ddclient() {
local TZ_VAL; TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" local TZ_VAL; TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
backup_if_exists docker-compose.yml
cat > docker-compose.yml << 'DDCLIENT_COMPOSE' cat > docker-compose.yml << 'DDCLIENT_COMPOSE'
name: ddclient name: ddclient
@@ -229,6 +235,7 @@ services:
- ./config:/config - ./config:/config
DDCLIENT_COMPOSE DDCLIENT_COMPOSE
backup_if_exists .env
cat > .env << DDCLIENT_ENV cat > .env << DDCLIENT_ENV
TZ=$TZ_VAL TZ=$TZ_VAL
DDCLIENT_ENV DDCLIENT_ENV
+7
View File
@@ -178,6 +178,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -477,6 +482,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << DRUM_COMPOSE cat > docker-compose.yml << DRUM_COMPOSE
name: drum-rhythm-game name: drum-rhythm-game
@@ -492,6 +498,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
DRUM_COMPOSE DRUM_COMPOSE
backup_if_exists .env
cat > .env << DRUM_ENV cat > .env << DRUM_ENV
CADDY_NET=${SITE_CADDY_NET} CADDY_NET=${SITE_CADDY_NET}
DRUM_ENV DRUM_ENV
+7
View File
@@ -189,6 +189,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -352,6 +357,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << EMBY_COMPOSE cat > docker-compose.yml << EMBY_COMPOSE
name: $CONTAINER name: $CONTAINER
@@ -377,6 +383,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
EMBY_COMPOSE EMBY_COMPOSE
backup_if_exists .env
cat > .env << EMBY_ENV cat > .env << EMBY_ENV
MEDIA_PATH=$MEDIA_PATH MEDIA_PATH=$MEDIA_PATH
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
+7
View File
@@ -177,6 +177,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -315,6 +320,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << FB_COMPOSE cat > docker-compose.yml << FB_COMPOSE
name: $CONTAINER name: $CONTAINER
@@ -334,6 +340,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
FB_COMPOSE FB_COMPOSE
backup_if_exists .env
cat > .env << FB_ENV cat > .env << FB_ENV
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
FB_ENV FB_ENV
+7
View File
@@ -199,6 +199,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -339,6 +344,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << FMD_COMPOSE cat > docker-compose.yml << FMD_COMPOSE
name: $CONTAINER name: $CONTAINER
@@ -357,6 +363,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
FMD_COMPOSE FMD_COMPOSE
backup_if_exists .env
cat > .env << FMD_ENV cat > .env << FMD_ENV
FMD_REGISTRATIONTOKEN=$FMD_TOKEN FMD_REGISTRATIONTOKEN=$FMD_TOKEN
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
+7
View File
@@ -207,6 +207,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -365,6 +370,7 @@ install_frigate-audio() {
# ── .env ────────────────────────────────────────────────────────────────── # ── .env ──────────────────────────────────────────────────────────────────
log_info "Writing .env..." log_info "Writing .env..."
backup_if_exists "$DIR/.env"
cat > "$DIR/.env" << ENVEOF cat > "$DIR/.env" << ENVEOF
# Frigate audio stack — generated by setup.sh # Frigate audio stack — generated by setup.sh
# DO NOT commit this file — it contains credentials. # DO NOT commit this file — it contains credentials.
@@ -432,6 +438,7 @@ networks:
" "
fi fi
backup_if_exists "$DIR/docker-compose.yml"
cat > "$DIR/docker-compose.yml" << 'COMPOSEEOF' cat > "$DIR/docker-compose.yml" << 'COMPOSEEOF'
# Frigate NVR + Mosquitto MQTT + frigate-notify # Frigate NVR + Mosquitto MQTT + frigate-notify
# Generated by ubuntu-post-install setup.sh # Generated by ubuntu-post-install setup.sh
+6
View File
@@ -174,6 +174,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -234,6 +239,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << FN_COMPOSE cat > docker-compose.yml << FN_COMPOSE
name: frigate-notify name: frigate-notify
+152 -8
View File
@@ -83,7 +83,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
} }
configure_caddy_for_service() { configure_caddy_for_service() {
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" _rp_extra="${5:-}"
local _caddy_dir="$DOCKER_DIR/caddy" local _caddy_dir="$DOCKER_DIR/caddy"
local _caddyfile="$_caddy_dir/Caddyfile" local _caddyfile="$_caddy_dir/Caddyfile"
local _display_port="${_upstream##*:}" local _display_port="${_upstream##*:}"
@@ -126,12 +126,23 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
_block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
fi fi
local _rp_line="reverse_proxy ${_block_upstream}"
if [[ -n "$_rp_extra" ]]; then
_rp_line="reverse_proxy ${_block_upstream} {
${_rp_extra}
}"
fi
local _site_block local _site_block
_site_block="$(cat << CBLOCK _site_block="$(cat << CBLOCK
# $_name # $_name
${_domain} { ${_domain} {
reverse_proxy ${_block_upstream} # Auth (if any) must come before reverse_proxy — see lib/common.sh's
# configure_caddy_for_service for why (reverse_proxy first would answer
# every request itself, making an auth block after it dead code).
${_extra}
${_rp_line}
header { header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
@@ -144,7 +155,6 @@ ${_domain} {
output file /var/log/caddy/${_domain}.log output file /var/log/caddy/${_domain}.log
format json format json
} }
${_extra}
} }
CBLOCK CBLOCK
)" )"
@@ -192,6 +202,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -526,6 +541,10 @@ install_frigate() {
echo " - Prompt to add cameras interactively (RTSP creds go in .env)" echo " - Prompt to add cameras interactively (RTSP creds go in .env)"
echo " or write a starter config.yml if none are added" echo " or write a starter config.yml if none are added"
echo " - Offer a Caddy reverse proxy and to start the container" echo " - Offer a Caddy reverse proxy and to start the container"
echo " - If Authelia is installed: offer to protect Frigate with it —"
echo " disables Frigate's own login (auth.enabled: False) and pins a"
echo " proxy.auth_secret/X-Proxy-Secret handshake so only Caddy can"
echo " satisfy Frigate's proxy-auth trust"
return 0 return 0
fi fi
@@ -612,6 +631,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << FRIGATE_COMPOSE cat > docker-compose.yml << FRIGATE_COMPOSE
name: frigate name: frigate
@@ -646,6 +666,106 @@ FRIGATE_COMPOSE
mkdir -p config mkdir -p config
mkdir -p "$FRIGATE_MEDIA" mkdir -p "$FRIGATE_MEDIA"
# Authelia SSO — decided (and, if accepted, wired into Caddy) before
# config.yml is written, so the auth block baked into config.yml only
# ever reflects a gate that's actually in place (never "native login
# disabled, but nothing put in front of it instead"). Frigate has its
# own built-in login (username/password) separate from Authelia's —
# left alone it would show *after* Authelia's forward_auth already
# gated the domain: a redundant second login, and worse, a second
# session that can expire independently and force a re-login on its
# own schedule regardless of Authelia's "remember me" duration. The
# proxy.auth_secret/X-Proxy-Secret handshake (pinned into the Caddy
# reverse_proxy block) stops that trust from being spoofed by a
# request that reaches Frigate's published host port directly,
# bypassing Caddy/Authelia entirely.
# Whether to even OFFER this can't just check "$DOCKER_DIR/authelia"
# locally — Frigate's own box very often has no local Caddy at all
# (configure_caddy_for_service falls back to writing a snippet for
# a remote Caddy machine to pick up, confirmed live: this is the
# normal shape for a dedicated NVR box), in which case Authelia, if
# it exists anywhere, lives on THAT remote Caddy machine instead —
# a box this script has no filesystem access to inspect. Default to
# "y" only when local Authelia is actually confirmed; otherwise still
# offer it (default "n") and sort out local-vs-remote below once the
# admin says yes, rather than silently refusing to ask at all.
local FRIGATE_USE_AUTHELIA="n" FRIGATE_PROXY_SECRET="" AUTH_CONFIG_BLOCK=""
local _frigate_local_authelia="n"
[ -d "$DOCKER_DIR/authelia" ] && _frigate_local_authelia="y"
echo ""
prompt_yn "Protect Frigate with Authelia SSO (disables Frigate's own login)? (y/n):" "$_frigate_local_authelia" FRIGATE_USE_AUTHELIA
if [[ "$FRIGATE_USE_AUTHELIA" =~ ^[Yy]$ ]]; then
FRIGATE_PROXY_SECRET="${ENV_MAP[FRIGATE_PROXY_AUTH_SECRET]:-$(generate_password 32)}"
# Local Authelia snippet (import authelia) only actually exists
# in the Caddyfile it's imported into if Authelia is on THAT
# same machine. When it's on this box, that's this box's own
# Caddy — safe to assume. When Caddy itself turns out to be
# remote (below), "local" instead means "local to wherever
# Caddy is", which this script can't see — so ask, rather than
# silently emit an import that would fail Caddy's own reload
# with "file to import not found" on that other machine.
local _frigate_auth_block=" import authelia"
if [ "$_frigate_local_authelia" != "y" ]; then
echo ""
log_info "No local Authelia on this box — Caddy for Frigate may end up on a"
log_info "different machine (decided next)."
local _authelia_with_caddy=""
prompt_yn " Does Authelia run on that SAME machine as Caddy? (y/n):" "y" _authelia_with_caddy
if [[ ! "$_authelia_with_caddy" =~ ^[Yy]$ ]]; then
# Genuinely cross-machine: Authelia is a third box,
# different from both this one and wherever Caddy ends
# up. Needs the explicit header-pinned forward_auth form
# — see CLAUDE.md's "forward_auth to a remote Authelia"
# note for why the bare "authelia:9091" shortcut can't
# be used here and X-Forwarded-Host must be pinned
# explicitly (a second Caddy hop otherwise silently
# evaluates every domain as if it were auth's own
# portal domain — confirmed live, a real incident this
# exact snippet shape was written to prevent).
local _remote_authelia_domain=""
prompt_text " Authelia's own portal domain (e.g. authelia.example.com):" "" _remote_authelia_domain
if [ -n "$_remote_authelia_domain" ]; then
_frigate_auth_block=" forward_auth https://${_remote_authelia_domain} {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
header_up X-Forwarded-Method {method}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Uri {uri}
}"
else
log_warning "No domain entered — falling back to 'import authelia', which will fail"
log_warning "Caddy's reload unless Authelia is actually local to that Caddy machine."
fi
fi
fi
configure_caddy_for_service "Frigate" "frigate:5000" "frigate" \
"$_frigate_auth_block" \
" header_up X-Proxy-Secret ${FRIGATE_PROXY_SECRET}"
if [ "${CADDY_SERVICE_CONFIGURED:-false}" = true ]; then
AUTH_CONFIG_BLOCK="auth:
enabled: False # Authelia already gates the whole domain — its own login would be redundant
proxy:
auth_secret: \"{FRIGATE_PROXY_AUTH_SECRET}\" # must match the X-Proxy-Secret header Caddy sends
header_map:
user: remote-user
role: remote-groups
default_role: admin # anyone who passes Authelia gets full access, same as the disabled local login did
"
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "frigate" "$CADDY_SERVICE_DOMAIN"
else
log_warning "Caddy wasn't configured for Frigate — leaving Frigate's own login enabled (nothing else is gating access)."
FRIGATE_PROXY_SECRET=""
fi
else
configure_caddy_for_service "Frigate" "frigate:5000" "frigate"
fi
# Credentials/IPs go in .env as FRIGATE_* variables; Frigate substitutes # Credentials/IPs go in .env as FRIGATE_* variables; Frigate substitutes
# any {FRIGATE_VAR} placeholder in config.yml from its container env at # any {FRIGATE_VAR} placeholder in config.yml from its container env at
# startup, so RTSP secrets never need to be typed into the YAML directly. # startup, so RTSP secrets never need to be typed into the YAML directly.
@@ -654,12 +774,12 @@ FRIGATE_COMPOSE
if [ "${#CAM_NAME[@]}" -eq 0 ]; then if [ "${#CAM_NAME[@]}" -eq 0 ]; then
# No cameras entered — write a starter config the operator edits by hand. # No cameras entered — write a starter config the operator edits by hand.
cat > config/config.yml << 'FRIGATE_CONFIG' cat > config/config.yml << FRIGATE_CONFIG
# Frigate Configuration — Docs: https://docs.frigate.video # Frigate Configuration — Docs: https://docs.frigate.video
# #
# ⚠️ YOU MUST EDIT THIS FILE to add your cameras before starting Frigate. # ⚠️ YOU MUST EDIT THIS FILE to add your cameras before starting Frigate.
mqtt: ${AUTH_CONFIG_BLOCK}mqtt:
enabled: false # Set to true and configure if you use Home Assistant enabled: false # Set to true and configure if you use Home Assistant
cameras: cameras:
@@ -696,7 +816,7 @@ FRIGATE_CONFIG
# RTSP credentials/IPs come from .env — Frigate substitutes {FRIGATE_VAR} # RTSP credentials/IPs come from .env — Frigate substitutes {FRIGATE_VAR}
# placeholders below from the container's environment at startup. # placeholders below from the container's environment at startup.
mqtt: ${AUTH_CONFIG_BLOCK}mqtt:
enabled: false # Set to true and configure if you use Home Assistant enabled: false # Set to true and configure if you use Home Assistant
go2rtc: go2rtc:
@@ -721,9 +841,11 @@ snapshots:
FRIGATE_CONFIG FRIGATE_CONFIG
fi fi
backup_if_exists .env
cat > .env << FRIGATE_ENV cat > .env << FRIGATE_ENV
FRIGATE_MEDIA=$FRIGATE_MEDIA FRIGATE_MEDIA=$FRIGATE_MEDIA
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
FRIGATE_PROXY_AUTH_SECRET=$FRIGATE_PROXY_SECRET
${ENV_CAM_VARS} ${ENV_CAM_VARS}
FRIGATE_ENV FRIGATE_ENV
chmod 600 .env chmod 600 .env
@@ -732,7 +854,29 @@ FRIGATE_ENV
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FRIGATE_MEDIA" 2>/dev/null || true chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FRIGATE_MEDIA" 2>/dev/null || true
log_success "Frigate configured at $FRIGATE_DIR" log_success "Frigate configured at $FRIGATE_DIR"
configure_caddy_for_service "Frigate" "frigate:5000" "frigate" local AUTH_README_SECTION=""
if [ -n "$AUTH_CONFIG_BLOCK" ]; then
AUTH_README_SECTION="
## Authelia SSO
Frigate's own login is disabled (\`auth.enabled: False\` in
\`config/config.yml\`) — Authelia gates the whole domain instead via Caddy's
\`import authelia\` plus a \`proxy.auth_secret\`/\`X-Proxy-Secret\` handshake
(the secret lives in \`.env\` as \`FRIGATE_PROXY_AUTH_SECRET\`) so that trust
can't be spoofed by a request that reaches Frigate's published port
directly, bypassing Caddy.
Everyone who passes Authelia gets full (admin) access to Frigate —
adjust \`config/config.yml\`'s \`proxy.role_map\`/\`default_role\` plus
Authelia's own group assignments if you want to give some users
view-only access instead.
To stop Authelia asking for a login again on repeat visits (e.g. from a
phone) for as long as possible, increase its \"remember me\" session
duration: \`sudo ./setup.sh authelia\` → \"Change 'remember me' session
duration\" (this affects every domain that instance protects, not just
Frigate).
"
fi
write_readme "$FRIGATE_DIR" << MD write_readme "$FRIGATE_DIR" << MD
# Frigate NVR # Frigate NVR
@@ -746,7 +890,7 @@ security cameras. Detects people, cars, animals, and more.
- Recordings: \`$FRIGATE_MEDIA\` - Recordings: \`$FRIGATE_MEDIA\`
- Config: \`config/config.yml\` — cameras configured during install (${#CAM_NAME[@]} total) - Config: \`config/config.yml\` — cameras configured during install (${#CAM_NAME[@]} total)
- Credentials: \`.env\` — RTSP user/pass/IP per camera as FRIGATE_* variables - Credentials: \`.env\` — RTSP user/pass/IP per camera as FRIGATE_* variables
${AUTH_README_SECTION}
## Manage ## Manage
\`\`\`bash \`\`\`bash
cd $FRIGATE_DIR cd $FRIGATE_DIR
+7
View File
@@ -92,6 +92,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -199,6 +204,7 @@ install_garage-webui() {
ensure_docker_dir_ownership "$DIR" ensure_docker_dir_ownership "$DIR"
cd "$DIR" || return 1 cd "$DIR" || return 1
backup_if_exists docker-compose.yml
cat > docker-compose.yml << COMPOSE cat > docker-compose.yml << COMPOSE
name: garage-webui name: garage-webui
@@ -219,6 +225,7 @@ services:
- "${WEB_PORT}:3909" - "${WEB_PORT}:3909"
COMPOSE COMPOSE
backup_if_exists .env
cat > .env << ENV cat > .env << ENV
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)} TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
+7
View File
@@ -108,6 +108,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -277,6 +282,7 @@ api_bind_addr = "[::]:${ADMIN_PORT}"
admin_token = "${ADMIN_TOKEN}" admin_token = "${ADMIN_TOKEN}"
TOML TOML
backup_if_exists docker-compose.yml
cat > docker-compose.yml << COMPOSE cat > docker-compose.yml << COMPOSE
name: garage name: garage
@@ -297,6 +303,7 @@ services:
- "${ADMIN_PORT}:${ADMIN_PORT}" - "${ADMIN_PORT}:${ADMIN_PORT}"
COMPOSE COMPOSE
backup_if_exists .env
cat > .env << ENV cat > .env << ENV
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)} TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
+7
View File
@@ -187,6 +187,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -405,6 +410,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << GATUS_COMPOSE cat > docker-compose.yml << GATUS_COMPOSE
name: gatus name: gatus
@@ -423,6 +429,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
GATUS_COMPOSE GATUS_COMPOSE
backup_if_exists .env
cat > .env << GATUS_ENV cat > .env << GATUS_ENV
TZ=$TZ_VAL TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
+119 -3
View File
@@ -104,6 +104,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -214,13 +219,13 @@ _gitea_offer_authelia_sso() {
prompt_yn " Require two-factor for Gitea logins via Authelia too? (y/n):" "y" _2fa prompt_yn " Require two-factor for Gitea logins via Authelia too? (y/n):" "y" _2fa
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor" [[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
if ! _authelia_provision_oidc_client "Gitea" "gitea" "$AUTH_POLICY" "y" \ if ! _authelia_provision_oidc_client "Gitea" "gitea" "$AUTH_POLICY" "y" "n" "" \
"https://${GITEA_OIDC_DOMAIN}/user/oauth2/authelia/callback"; then "https://${GITEA_OIDC_DOMAIN}/user/oauth2/authelia/callback"; then
log_warning "Couldn't register Gitea as an OIDC client in Authelia — skipping SSO setup." log_warning "Couldn't register Gitea as an OIDC client in Authelia — skipping SSO setup."
return 0 return 0
fi fi
local _discovery_url="https://auth.${OIDC_AUTHELIA_DOMAIN}/.well-known/openid-configuration" local _discovery_url="${OIDC_AUTHELIA_PORTAL_URL}/.well-known/openid-configuration"
log_info "Adding Authelia as an authentication source in Gitea..." log_info "Adding Authelia as an authentication source in Gitea..."
if docker exec -u git gitea gitea admin auth add-oauth \ if docker exec -u git gitea gitea admin auth add-oauth \
--name authelia --provider openidConnect \ --name authelia --provider openidConnect \
@@ -240,6 +245,92 @@ _gitea_offer_authelia_sso() {
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "gitea" "$GITEA_OIDC_DOMAIN" declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "gitea" "$GITEA_OIDC_DOMAIN"
} }
# Offers Gitea's OTHER Authelia integration — not the OIDC button above, but
# ENABLE_REVERSE_PROXY_AUTHENTICATION: Gitea auto-logs in as whatever user
# name arrives in a trusted header, no click and no separate Gitea session
# to expire on its own schedule. This is genuinely stronger than the OIDC
# button (which still shows a login page, just with an extra option on it)
# and matches the pattern services/frigate.sh uses — except Gitea's own
# login form stays available as a fallback for anyone NOT arriving from a
# trusted source, so there's no "native login disabled with nothing gating
# it" failure mode to guard against here the way Frigate's had.
#
# The security boundary is REVERSE_PROXY_TRUSTED_PROXIES, not a shared
# secret: Gitea only honors the identity header from source IPs inside that
# range. Gitea's own Docker image shipped this wildcarded (GHSA-f75j-4cw6-
# rmx4 — any IP could set X-WEBAUTH-USER and log in as anyone), so this is
# always computed from caddy_net's real subnet (same lookup
# ufw_allow_from_caddy_net uses) and refuses to enable the feature at all if
# that can't be determined — never falls back to a permissive default.
#
# Requires Gitea to actually be reachable from an address inside that range,
# which means joining caddy_net like every other locally-Caddy-fronted
# service in this repo (Gitea currently reaches Caddy via its published
# host port instead — host.docker.internal upstream — because it predates
# this feature). Local Caddy only: a remote Caddy machine's source address
# isn't a stable, narrowly-scopeable range the way caddy_net's bridge subnet
# is, so this skips remote mode rather than guess at a trust range worth
# getting wrong.
_gitea_offer_reverse_proxy_auth() {
local DIR="$1"
[ -d "$DOCKER_DIR/authelia" ] || return 0
[ -d "$DOCKER_DIR/caddy" ] || return 0
if grep -q 'ENABLE_REVERSE_PROXY_AUTHENTICATION=true' "$DIR/docker-compose.yml" 2>/dev/null; then
log_info "Gitea's zero-click Authelia login (reverse-proxy auth) is already enabled — skipping."
return 0
fi
echo ""
local USE_RP=""
prompt_yn " Skip Gitea's own login entirely for anyone arriving via Authelia — fully transparent, no click, no separate Gitea session to re-expire? Rewires Gitea onto Caddy's internal network (Caddy must be on this same machine). (y/n):" "n" USE_RP
[[ "$USE_RP" =~ ^[Yy]$ ]] || return 0
local _subnet
_subnet="$(docker network inspect "${SITE_CADDY_NET:-caddy_net}" \
--format '{{range .IPAM.Config}}{{.Subnet}}{{end}}' 2>/dev/null)"
if [ -z "$_subnet" ]; then
log_warning "Couldn't determine ${SITE_CADDY_NET:-caddy_net}'s subnet — refusing to enable"
log_warning "reverse-proxy auth without a scoped trust range. An unscoped default lets ANY"
log_warning "client impersonate ANY Gitea user via a spoofed header (this was a real Gitea"
log_warning "CVE — GHSA-f75j-4cw6-rmx4). Skipping."
return 1
fi
log_info "Wiring Gitea onto caddy_net and enabling reverse-proxy authentication..."
sed -i "/GITEA__security__INSTALL_LOCK=true/a\\ - GITEA__service__ENABLE_REVERSE_PROXY_AUTHENTICATION=true\\n - GITEA__service__ENABLE_REVERSE_PROXY_AUTO_REGISTRATION=true\\n - GITEA__service__ENABLE_REVERSE_PROXY_EMAIL=true\\n - GITEA__security__REVERSE_PROXY_AUTHENTICATION_USER=Remote-User\\n - GITEA__security__REVERSE_PROXY_AUTHENTICATION_EMAIL=Remote-Email\\n - GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES=${_subnet}" \
"$DIR/docker-compose.yml"
cat >> "$DIR/docker-compose.yml" << EOF
networks:
- caddy_net
networks:
caddy_net:
external: true
name: ${SITE_CADDY_NET:-caddy_net}
EOF
_gitea_fix_ownership "$DIR"
(cd "$DIR" && docker compose up -d) \
&& log_success "Gitea restarted on caddy_net (trusted range: ${_subnet})." \
|| { log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"; return 1; }
# Re-point Caddy at the container (gitea:3000, now reachable over
# caddy_net) instead of the host-published port, with the auth gate in
# front. This replaces the plain block set up earlier in this install —
# configure_caddy_for_service's own "already exists — overwrite?" prompt
# covers that; nothing here bypasses it.
configure_caddy_for_service "Gitea" "gitea:3000" "git" " import authelia"
if [ "${CADDY_SERVICE_CONFIGURED:-false}" = true ]; then
log_success "Gitea now signs in transparently via Authelia at https://${CADDY_SERVICE_DOMAIN} — its own login page is still there for anyone reaching it another way."
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "gitea" "$CADDY_SERVICE_DOMAIN"
else
log_warning "Caddy wasn't reconfigured — env vars are set, but nothing is routing Gitea through Authelia yet."
log_warning "Point Gitea's Caddy entry at gitea:3000 (not the old host.docker.internal upstream) with 'import authelia' in front, or just re-run this offer."
fi
}
# Offers to enable Gitea Actions (Gitea's own CI, largely GitHub-Actions- # Offers to enable Gitea Actions (Gitea's own CI, largely GitHub-Actions-
# workflow-compatible) with a local runner — mainly useful as a fallback so # workflow-compatible) with a local runner — mainly useful as a fallback so
# .gitea/workflows/*.yml can still run something like a GitHub Actions build # .gitea/workflows/*.yml can still run something like a GitHub Actions build
@@ -255,7 +346,11 @@ _gitea_offer_authelia_sso() {
_gitea_offer_actions_runner() { _gitea_offer_actions_runner() {
local DIR="$1" local DIR="$1"
grep -q '^ act_runner:$' "$DIR/docker-compose.yml" 2>/dev/null && return 0 if grep -q '^ act_runner:$' "$DIR/docker-compose.yml" 2>/dev/null; then
log_info "Gitea Actions runner is already set up (act_runner service already in docker-compose.yml) — skipping."
log_info "Check its status: docker compose -f $DIR/docker-compose.yml ps act_runner"
return 0
fi
echo "" echo ""
local USE_ACTIONS="" local USE_ACTIONS=""
@@ -441,6 +536,8 @@ install_gitea() {
echo "[DRY-RUN] to install a systemd timer for automatic sync, or print manual instructions" echo "[DRY-RUN] to install a systemd timer for automatic sync, or print manual instructions"
echo "[DRY-RUN] Would offer to run a sync now (dry-run preview or for real), off-schedule" echo "[DRY-RUN] Would offer to run a sync now (dry-run preview or for real), off-schedule"
echo "[DRY-RUN] Would offer \"Sign in with Authelia\" (OIDC) if Authelia is installed" echo "[DRY-RUN] Would offer \"Sign in with Authelia\" (OIDC) if Authelia is installed"
echo "[DRY-RUN] Would offer zero-click Authelia login (reverse-proxy auth) if Authelia"
echo "[DRY-RUN] and local Caddy are both installed — rewires Gitea onto caddy_net"
echo "[DRY-RUN] Would offer to enable Gitea Actions (CI) with a local act_runner container" echo "[DRY-RUN] Would offer to enable Gitea Actions (CI) with a local act_runner container"
echo "[DRY-RUN] Would write $DIR/README.md" echo "[DRY-RUN] Would write $DIR/README.md"
return 0 return 0
@@ -469,6 +566,7 @@ install_gitea() {
|| log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs" || log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"
_gitea_run_sync_direction_step "$DIR" _gitea_run_sync_direction_step "$DIR"
_gitea_offer_authelia_sso "$DIR" _gitea_offer_authelia_sso "$DIR"
_gitea_offer_reverse_proxy_auth "$DIR"
_gitea_offer_actions_runner "$DIR" _gitea_offer_actions_runner "$DIR"
log_success "Existing .env (tokens) and web/SSH ports were left untouched." log_success "Existing .env (tokens) and web/SSH ports were left untouched."
return 0 return 0
@@ -492,6 +590,7 @@ install_gitea() {
[[ "$WEB_PORT" != 3001 ]] && log_info "Port 3001 was taken — Gitea's web UI will use ${WEB_PORT}." [[ "$WEB_PORT" != 3001 ]] && log_info "Port 3001 was taken — Gitea's web UI will use ${WEB_PORT}."
[[ "$SSH_PORT" != 2222 ]] && log_info "Port 2222 was taken — Gitea's SSH clone port will use ${SSH_PORT}." [[ "$SSH_PORT" != 2222 ]] && log_info "Port 2222 was taken — Gitea's SSH clone port will use ${SSH_PORT}."
backup_if_exists docker-compose.yml
cat > docker-compose.yml << EOF cat > docker-compose.yml << EOF
name: gitea name: gitea
services: services:
@@ -606,6 +705,7 @@ EOF
cp -f "$SYNC_SRC" "$DIR/gitea-github-sync.sh" cp -f "$SYNC_SRC" "$DIR/gitea-github-sync.sh"
chmod +x "$DIR/gitea-github-sync.sh" chmod +x "$DIR/gitea-github-sync.sh"
backup_if_exists "$DIR/.env"
cat > "$DIR/.env" << ENV cat > "$DIR/.env" << ENV
# Written by services/gitea.sh — re-run that (update mode) to change any of this. # Written by services/gitea.sh — re-run that (update mode) to change any of this.
GITEA_URL='http://localhost:${WEB_PORT}' GITEA_URL='http://localhost:${WEB_PORT}'
@@ -639,6 +739,7 @@ ENV
configure_caddy_for_service "Gitea" "host.docker.internal:${WEB_PORT}" "git" configure_caddy_for_service "Gitea" "host.docker.internal:${WEB_PORT}" "git"
_gitea_offer_authelia_sso "$DIR" _gitea_offer_authelia_sso "$DIR"
_gitea_offer_reverse_proxy_auth "$DIR"
_gitea_offer_actions_runner "$DIR" _gitea_offer_actions_runner "$DIR"
write_readme "$DIR" << MD write_readme "$DIR" << MD
@@ -681,6 +782,21 @@ on Gitea's own login page. Local admin login keeps working exactly as
before — this is additive, not a replacement. Managed in Gitea under before — this is additive, not a replacement. Managed in Gitea under
Site Administration -> Authentication Sources (source name: \`authelia\`). Site Administration -> Authentication Sources (source name: \`authelia\`).
## Zero-click Authelia login (optional, stronger)
A second, separate Authelia integration: instead of an extra button on
Gitea's login page, Gitea auto-logs in as whoever Authelia says you are —
no click, and no separate Gitea session that can expire on its own and
force a re-login later. Re-run \`sudo ./setup.sh gitea\` (Update mode) and
answer yes to the "Skip Gitea's own login entirely..." prompt. Requires
Authelia and Caddy on this same machine — it moves Gitea onto Caddy's
internal Docker network (\`caddy_net\`) and Gitea only trusts the identity
header from that network's address range, not from the internet or from
its own host-published port. Gitea's own login page keeps working for
anyone who reaches it any other way (e.g. directly on its port). New
users arriving this way get an ordinary (non-admin) Gitea account created
automatically the first time they show up.
## Gitea Actions (CI) — optional local runner ## Gitea Actions (CI) — optional local runner
Re-run \`sudo ./setup.sh gitea\` (Update mode is fine) and answer yes to Re-run \`sudo ./setup.sh gitea\` (Update mode is fine) and answer yes to
+1
View File
@@ -274,6 +274,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << HOMEASSISTANT_COMPOSE cat > docker-compose.yml << HOMEASSISTANT_COMPOSE
name: homeassistant name: homeassistant
+130
View File
@@ -177,6 +177,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -196,6 +201,124 @@ fi
register_service homebox utilities "Home inventory and asset management (Homebox)" 7745 register_service homebox utilities "Home inventory and asset management (Homebox)" 7745
# Offers to wire Homebox's own native OIDC support to Authelia — real
# server-side automation via env vars, not paste-in instructions, same
# shape as Mealie/ActualBudget. Confirmed against homebox.software's own
# OIDC docs and authelia.com's Homebox integration page directly: PKCE is
# required, redirect path is /api/v1/users/login/oidc/callback, and the
# issuer URL is sensitive to a trailing slash (a real reported bug) — the
# portal URL this repo already stores never has one, so left as-is here.
#
# Args: DIR CONTAINER
_homebox_offer_authelia_oidc() {
local DIR="$1" CONTAINER="$2"
declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0
[ -d "$DOCKER_DIR/authelia" ] || return 0
echo ""
local USE_SSO=""
prompt_yn " Add \"Sign in with Authelia\" (OpenID Connect) to Homebox? (y/n):" "n" USE_SSO
[[ "$USE_SSO" =~ ^[Yy]$ ]] || return 0
if grep -q '^HBOX_OIDC_ENABLED=' "$DIR/.env" 2>/dev/null; then
echo ""
log_info "Authelia SSO is already configured for Homebox (HBOX_OIDC_* already set in $DIR/.env)."
local RECONFIGURE=""
prompt_yn " Reconfigure it (registers a fresh Authelia client + secret)? (y/n):" "n" RECONFIGURE
[[ "$RECONFIGURE" =~ ^[Yy]$ ]] || return 0
sed -i '/^HBOX_OIDC_/d; /^HBOX_OPTIONS_TRUST_PROXY=/d' "$DIR/.env"
fi
# Existing installs from before this offer existed won't have env_file
# picked up their .env's OIDC additions otherwise — this repo's own
# compose template gained it above; a pre-existing compose file needs
# the same one-line patch to actually load what's about to be written.
if ! grep -q '^\s*env_file: \.env\s*$' "$DIR/docker-compose.yml" 2>/dev/null; then
sed -i "/^ hostname: /a\\ env_file: .env" "$DIR/docker-compose.yml"
fi
local APP_DOMAIN
APP_DOMAIN="$(_authelia_pick_domain "Domain Homebox is reachable at (number or domain)")"
if [ -z "$APP_DOMAIN" ]; then
log_warning "No domain entered — skipping SSO setup."
return 0
fi
local _2fa="" AUTH_POLICY="two_factor"
prompt_yn " Require two-factor for Homebox logins via Authelia too? (y/n):" "y" _2fa
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
if ! _authelia_provision_oidc_client "Homebox" "homebox" "$AUTH_POLICY" "y" "y" "groups" \
"https://${APP_DOMAIN}/api/v1/users/login/oidc/callback"; then
log_warning "Couldn't register Homebox as an OIDC client in Authelia — skipping SSO setup."
return 0
fi
cat >> "$DIR/.env" << ENV
# Written by services/homebox.sh's Authelia SSO step — adds "Sign in with
# Authelia" alongside local login; local accounts keep working unchanged.
HBOX_OIDC_ENABLED=true
HBOX_OIDC_ISSUER_URL=${OIDC_AUTHELIA_PORTAL_URL}
HBOX_OIDC_CLIENT_ID=homebox
HBOX_OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET_PLAIN}
HBOX_OIDC_SCOPE=openid profile email groups
HBOX_OPTIONS_TRUST_PROXY=true
ENV
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/.env" 2>/dev/null || true
(cd "$DIR" && docker compose up -d) \
&& log_success "\"Sign in with Authelia\" added to Homebox — local login still works too." \
|| log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "homebox" "$APP_DOMAIN"
echo ""
log_info "Test the \"Login with Authelia\" button on Homebox's own login page before"
log_info "disabling local login — re-run 'sudo ./setup.sh homebox' (choose update) once"
log_info "you've confirmed it works, and you'll be offered that as a separate step."
}
# Split out from _homebox_offer_authelia_oidc so disabling local login is
# never offered in the same breath as first setting SSO up — same
# reasoning as Mealie/Beszel's equivalent split (confirmed live on Beszel:
# saying yes before actually testing the button leaves both login paths
# broken at once). Only reached from a later "update" rerun once OIDC is
# already configured and the admin declines to reconfigure.
_homebox_offer_disable_local_login() {
local DIR="$1"
grep -q '^HBOX_OPTIONS_ALLOW_LOCAL_LOGIN=false' "$DIR/.env" 2>/dev/null && return 0
echo ""
local _tested=""
prompt_yn " Have you ALREADY logged into Homebox successfully using the Authelia button (not just enabled it)? (y/n):" "n" _tested
if [[ ! "$_tested" =~ ^[Yy]$ ]]; then
log_info "Skipped. Test the Authelia login button first, then re-run 'sudo ./setup.sh homebox' (choose update) to come back to this."
return 0
fi
local _disable_local=""
prompt_yn " Also disable Homebox's own local login, so Authelia is the only way in? (y/n):" "n" _disable_local
[[ "$_disable_local" =~ ^[Yy]$ ]] || return 0
log_warning "Anyone without an Authelia account (only a local Homebox one) will no longer be able to log in."
log_info "Reversible any time: set HBOX_OPTIONS_ALLOW_LOCAL_LOGIN back to true in $DIR/.env and 'docker compose up -d'."
local _auto_redirect=""
prompt_yn " Skip Homebox's login page entirely and jump straight to Authelia? (y/n):" "y" _auto_redirect
sed -i '/^HBOX_OPTIONS_ALLOW_LOCAL_LOGIN=/d; /^HBOX_OIDC_AUTO_REDIRECT=/d' "$DIR/.env"
{
echo "HBOX_OPTIONS_ALLOW_LOCAL_LOGIN=false"
[[ "$_auto_redirect" =~ ^[Yy]$ ]] && echo "HBOX_OIDC_AUTO_REDIRECT=true"
} >> "$DIR/.env"
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/.env" 2>/dev/null || true
(cd "$DIR" && docker compose up -d) \
&& log_success "Local login is now disabled — Authelia is the only way into Homebox." \
|| log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"
}
install_homebox() { install_homebox() {
require_docker || return 1 require_docker || return 1
log_info "Installing Homebox..." log_info "Installing Homebox..."
@@ -256,6 +379,8 @@ install_homebox() {
( cd "$HB_DIR" && docker compose pull && docker compose up -d ) \ ( cd "$HB_DIR" && docker compose pull && docker compose up -d ) \
&& log_success "Homebox image refreshed" \ && log_success "Homebox image refreshed" \
|| log_warning "Refresh failed — check: docker compose -f $HB_DIR/docker-compose.yml logs" || log_warning "Refresh failed — check: docker compose -f $HB_DIR/docker-compose.yml logs"
_homebox_offer_authelia_oidc "$HB_DIR" "$CONTAINER"
_homebox_offer_disable_local_login "$HB_DIR"
return 0 return 0
;; ;;
cancel) cancel)
@@ -311,6 +436,7 @@ networks:
local HB_PEPPER local HB_PEPPER
HB_PEPPER="$(generate_password 48)" HB_PEPPER="$(generate_password 48)"
backup_if_exists docker-compose.yml
cat > docker-compose.yml << HB_COMPOSE cat > docker-compose.yml << HB_COMPOSE
name: $CONTAINER name: $CONTAINER
@@ -320,6 +446,7 @@ services:
container_name: $CONTAINER container_name: $CONTAINER
hostname: $CONTAINER hostname: $CONTAINER
restart: unless-stopped restart: unless-stopped
env_file: .env
environment: environment:
- HBOX_LOG_LEVEL=info - HBOX_LOG_LEVEL=info
- HBOX_WEB_MAX_UPLOAD_SIZE=10 - HBOX_WEB_MAX_UPLOAD_SIZE=10
@@ -331,6 +458,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
HB_COMPOSE HB_COMPOSE
backup_if_exists .env
cat > .env << HB_ENV cat > .env << HB_ENV
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
@@ -346,6 +474,8 @@ HB_ENV
configure_caddy_for_service "Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:7745" "homebox${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" configure_caddy_for_service "Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:7745" "homebox${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
_homebox_offer_authelia_oidc "$HB_DIR" "$CONTAINER"
write_readme "$HB_DIR" << MD write_readme "$HB_DIR" << MD
# Homebox${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} # Homebox${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
+148
View File
@@ -183,6 +183,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -201,6 +206,129 @@ fi
register_service immich media "Self-hosted photo & video backup — like Google Photos (Immich)" 2283 register_service immich media "Self-hosted photo & video backup — like Google Photos (Immich)" 2283
# Offers to wire Immich's own native OAuth support to Authelia — real
# server-side automation, not just paste-in instructions, unlike
# Audiobookshelf/Beszel below (neither exposes a config API; Immich does).
# Confirmed against docs.mealie.io's sibling page for Immich
# (docs.immich.app/administration/oauth) and, since that page doesn't
# document the underlying API, against Immich's own config-file.md and
# GitHub source directly for the exact JSON field names under the "oauth"
# key — not guessed. GET/PUT /api/system-config exchanges the WHOLE config
# object (there's no partial-patch endpoint), so this only ever touches the
# "oauth" sub-object and round-trips everything else completely unchanged
# — the same GET-modify-PUT shape already proven in this file for the
# storage-template step in import-photos.sh (search CURRENT_CONFIG above).
#
# Unlike Mealie/ActualBudget/Gitea, Immich's admin account isn't created by
# this installer — the user creates it themselves on first web visit (see
# "First launch" in the generated README) — so there's no API key to call
# with at the moment a FRESH install finishes. This is deliberately called
# from both the fresh-install path (where it'll usually just tell you to
# come back later) and the "update" rerun path (the realistic way most
# people actually complete this, once they have an account), same as
# _mealie_offer_authelia_oidc's own "works from either" design.
#
# Args: IMMICH_DIR WEB_PORT
_immich_offer_authelia_oidc() {
local DIR="$1" WEB_PORT="$2"
[ -d "$DOCKER_DIR/authelia" ] || return 0
declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0
echo ""
local USE_SSO=""
prompt_yn " Add \"Sign in with Authelia\" (OpenID Connect) to Immich? (y/n):" "n" USE_SSO
[[ "$USE_SSO" =~ ^[Yy]$ ]] || return 0
echo " This writes Immich's OAuth settings for you via its own API — needs an"
echo " admin API key: Administration -> Settings -> API Keys -> New API Key"
echo " (Admin scope). Leave blank to skip for now — safe to come back to this"
echo " later by re-running 'sudo ./setup.sh immich' once you have one."
local IMMICH_API_KEY=""
prompt_text " Immich admin API key:" "" IMMICH_API_KEY
if [ -z "$IMMICH_API_KEY" ]; then
log_info "Skipped — no account/API key yet. Come back to this by re-running"
log_info "'sudo ./setup.sh immich' (choose \"Manage that install\" -> update)."
return 0
fi
local IMMICH_LOCAL_URL="http://localhost:${WEB_PORT}"
local VERIFY_CODE
VERIFY_CODE="$(curl -s -o /dev/null -w '%{http_code}' -H "x-api-key: $IMMICH_API_KEY" "$IMMICH_LOCAL_URL/api/users/me" 2>/dev/null)"
if [ "$VERIFY_CODE" != "200" ]; then
log_warning "Couldn't verify that API key against Immich (HTTP $VERIFY_CODE) — skipping SSO setup."
return 0
fi
local APP_DOMAIN
APP_DOMAIN="$(_authelia_pick_domain "Domain Immich is reachable at (number or domain)")"
if [ -z "$APP_DOMAIN" ]; then
log_warning "No domain entered — skipping SSO setup."
return 0
fi
local _2fa="" AUTH_POLICY="two_factor"
prompt_yn " Require two-factor for Immich logins via Authelia too? (y/n):" "y" _2fa
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
# Same three redirect URIs as the "Immich" preset in authelia.sh's own
# generic OIDC menu (web login, account-linking, mobile app callback) —
# kept identical on purpose so either path produces the same client.
if ! _authelia_provision_oidc_client "Immich" "immich" "$AUTH_POLICY" "y" "n" "" \
"https://${APP_DOMAIN}/auth/login" "https://${APP_DOMAIN}/user-settings" "app.immich:///oauth-callback"; then
log_warning "Couldn't register Immich as an OIDC client in Authelia — skipping SSO setup."
return 0
fi
local _client_secret="$OIDC_CLIENT_SECRET_PLAIN" _portal_url="$OIDC_AUTHELIA_PORTAL_URL"
local CURRENT_CONFIG
CURRENT_CONFIG="$(curl -s -H "x-api-key: $IMMICH_API_KEY" "$IMMICH_LOCAL_URL/api/system-config" 2>/dev/null)"
if [ -z "$CURRENT_CONFIG" ] || ! command -v python3 &>/dev/null; then
log_warning "Couldn't read Immich's system config — set OAuth manually instead:"
echo " Administration -> Settings -> OAuth Authentication"
echo " Issuer URL: ${_portal_url}"
echo " Client ID: immich"
echo " Client Secret: ${_client_secret}"
return 0
fi
# Secret/issuer are passed via env vars, not interpolated into the
# python source as string literals — Authelia's generated secret uses
# an rfc3986 charset that isn't guaranteed free of characters (a stray
# quote, say) that would otherwise break out of a quoted Python literal.
local UPDATED_CONFIG
UPDATED_CONFIG="$(echo "$CURRENT_CONFIG" | OIDC_SECRET="$_client_secret" OIDC_ISSUER="$_portal_url" python3 -c "
import sys, json, os
config = json.load(sys.stdin)
config['oauth']['enabled'] = True
config['oauth']['issuerUrl'] = os.environ['OIDC_ISSUER']
config['oauth']['clientId'] = 'immich'
config['oauth']['clientSecret'] = os.environ['OIDC_SECRET']
config['oauth']['scope'] = 'openid email profile'
config['oauth']['buttonText'] = 'Login with Authelia'
json.dump(config, sys.stdout)
" 2>/dev/null)"
if [ -z "$UPDATED_CONFIG" ]; then
log_warning "Couldn't parse Immich's config — set OAuth manually: Administration -> Settings -> OAuth Authentication"
echo " Issuer URL: ${_portal_url} Client ID: immich Client Secret: ${_client_secret}"
return 0
fi
local RESULT
RESULT="$(curl -s -o /dev/null -w '%{http_code}' -X PUT \
-H "x-api-key: $IMMICH_API_KEY" -H "Content-Type: application/json" \
"$IMMICH_LOCAL_URL/api/system-config" -d "$UPDATED_CONFIG" 2>/dev/null)"
if [ "$RESULT" = "200" ]; then
log_success "\"Sign in with Authelia\" enabled in Immich — local login still works too."
else
log_warning "Couldn't set Immich's OAuth config (HTTP $RESULT) — set it manually instead:"
echo " Administration -> Settings -> OAuth Authentication"
echo " Issuer URL: ${_portal_url} Client ID: immich Client Secret: ${_client_secret}"
fi
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "immich" "$APP_DOMAIN"
}
install_immich() { install_immich() {
require_docker || return 1 require_docker || return 1
@@ -279,6 +407,15 @@ install_immich() {
( cd "$IMMICH_DIR" && docker compose pull && docker compose up -d ) \ ( cd "$IMMICH_DIR" && docker compose pull && docker compose up -d ) \
&& log_success "Immich image refreshed" \ && log_success "Immich image refreshed" \
|| log_warning "Refresh failed — check: docker compose -f $IMMICH_DIR/docker-compose.yml logs" || log_warning "Refresh failed — check: docker compose -f $IMMICH_DIR/docker-compose.yml logs"
# WEB_PORT isn't persisted anywhere but the compose
# file's own port mapping — re-derive it here rather
# than assuming the "2283" default this local started
# with, which may not match if it was shifted at
# install time (collision avoidance / another instance).
local _EXISTING_PORT
_EXISTING_PORT="$(grep -oP '^\s+- "?\K[0-9]+(?=:2283)' "$IMMICH_DIR/docker-compose.yml" 2>/dev/null | head -1)"
[ -n "$_EXISTING_PORT" ] && WEB_PORT="$_EXISTING_PORT"
_immich_offer_authelia_oidc "$IMMICH_DIR" "$WEB_PORT"
return 0 return 0
;; ;;
cancel) cancel)
@@ -474,6 +611,7 @@ networks:
[ -n "$EXTERNAL_LIBRARY" ] && _EXTERNAL_VOLUME_LINE=" - \${EXTERNAL_LIBRARY}:/usr/src/app/external:ro [ -n "$EXTERNAL_LIBRARY" ] && _EXTERNAL_VOLUME_LINE=" - \${EXTERNAL_LIBRARY}:/usr/src/app/external:ro
" "
backup_if_exists docker-compose.yml
cat > docker-compose.yml << IMMICH_COMPOSE cat > docker-compose.yml << IMMICH_COMPOSE
name: $PROJECT name: $PROJECT
@@ -554,6 +692,7 @@ S3_SECRET_ACCESS_KEY=$S3_SECRET_ACCESS_KEY
fi fi
if [ "$IMMICH_STRATEGY" = "2" ]; then if [ "$IMMICH_STRATEGY" = "2" ]; then
backup_if_exists .env
cat > .env << IMMICH_ENV cat > .env << IMMICH_ENV
# IMMICH CONFIGURATION — External Library Mode # IMMICH CONFIGURATION — External Library Mode
# #
@@ -581,6 +720,7 @@ TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
IMMICH_ENV IMMICH_ENV
else else
backup_if_exists .env
cat > .env << IMMICH_ENV cat > .env << IMMICH_ENV
# IMMICH CONFIGURATION — Unified Library # IMMICH CONFIGURATION — Unified Library
# #
@@ -886,6 +1026,14 @@ IMPORT_BODY
configure_caddy_for_service "Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${C_SERVER}:2283" "immich${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" configure_caddy_for_service "Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${C_SERVER}:2283" "immich${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
# Almost always a no-op on a truly fresh install — the admin account
# (and thus an API key) doesn't exist until the user visits the web UI
# for the first time, which hasn't happened yet at this point in the
# script. Still offered here for the rare case an instance is being
# reconfigured with credentials already in hand; the update rerun path
# above is the realistic way most people complete this.
_immich_offer_authelia_oidc "$IMMICH_DIR" "$WEB_PORT"
write_readme "$IMMICH_DIR" << MD write_readme "$IMMICH_DIR" << MD
# Immich${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} # Immich${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
+8
View File
@@ -185,6 +185,11 @@ CBLOCK
[[ "${DRY_RUN:-false}" == "true" ]] && return 0 [[ "${DRY_RUN:-false}" == "true" ]] && return 0
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -314,6 +319,7 @@ networks:
fi fi
if [[ "$USE_GPU" =~ ^[Yy]$ ]]; then if [[ "$USE_GPU" =~ ^[Yy]$ ]]; then
backup_if_exists docker-compose.yml
cat > docker-compose.yml << IOPAINT_GPU cat > docker-compose.yml << IOPAINT_GPU
name: iopaint name: iopaint
@@ -346,6 +352,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
IOPAINT_GPU IOPAINT_GPU
else else
backup_if_exists docker-compose.yml
cat > docker-compose.yml << IOPAINT_CPU cat > docker-compose.yml << IOPAINT_CPU
name: iopaint name: iopaint
@@ -373,6 +380,7 @@ IOPAINT_CPU
fi fi
# ── .env ───────────────────────────────────────────────────────────────── # ── .env ─────────────────────────────────────────────────────────────────
backup_if_exists .env
cat > .env << IOPAINT_ENV cat > .env << IOPAINT_ENV
# IOPaint — change MODEL and restart to switch (no need to edit docker-compose.yml) # IOPaint — change MODEL and restart to switch (no need to edit docker-compose.yml)
+7
View File
@@ -182,6 +182,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -347,6 +352,7 @@ networks:
' '
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << JELLYFIN_COMPOSE cat > docker-compose.yml << JELLYFIN_COMPOSE
name: $CONTAINER name: $CONTAINER
@@ -368,6 +374,7 @@ $HWACCEL_BLOCK
${_DISCOVERY_PORTS}${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_DISCOVERY_PORTS}${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
JELLYFIN_COMPOSE JELLYFIN_COMPOSE
backup_if_exists .env
cat > .env << JELLYFIN_ENV cat > .env << JELLYFIN_ENV
MEDIA_PATH=$MEDIA_PATH MEDIA_PATH=$MEDIA_PATH
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
+7
View File
@@ -177,6 +177,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -314,6 +319,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << JOPLIN_COMPOSE cat > docker-compose.yml << JOPLIN_COMPOSE
name: $CONTAINER name: $CONTAINER
@@ -340,6 +346,7 @@ ${_CADDY_NET_BLOCK}
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
JOPLIN_COMPOSE JOPLIN_COMPOSE
backup_if_exists .env
cat > .env << JOPLIN_ENV cat > .env << JOPLIN_ENV
# Joplin Server configuration # Joplin Server configuration
APP_PORT=22300 APP_PORT=22300
+6
View File
@@ -174,6 +174,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -459,6 +464,7 @@ networks:
" "
fi fi
backup_if_exists "$JS99ER_DIR/docker-compose.yml"
cat > "$JS99ER_DIR/docker-compose.yml" << COMPOSE cat > "$JS99ER_DIR/docker-compose.yml" << COMPOSE
name: js99er name: js99er
+7
View File
@@ -188,6 +188,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -410,6 +415,7 @@ install_koha() {
fi fi
# ── docker-compose.yml ──────────────────────────────────────────────────── # ── docker-compose.yml ────────────────────────────────────────────────────
backup_if_exists docker-compose.yml
cat > docker-compose.yml << KOHA_COMPOSE cat > docker-compose.yml << KOHA_COMPOSE
name: koha name: koha
@@ -481,6 +487,7 @@ ${_CADDY_NET_SECTION}
KOHA_COMPOSE KOHA_COMPOSE
# ── config-main.env ─────────────────────────────────────────────────────── # ── config-main.env ───────────────────────────────────────────────────────
backup_if_exists config-main.env
cat > config-main.env << KOHA_ENV cat > config-main.env << KOHA_ENV
# Koha ILS configuration — generated at install time # Koha ILS configuration — generated at install time
MYSQL_SERVER=koha-db MYSQL_SERVER=koha-db
+7
View File
@@ -388,6 +388,7 @@ for a in data.get('assets', []):
mkdir -p "$DIR" mkdir -p "$DIR"
ensure_docker_dir_ownership "$DIR" ensure_docker_dir_ownership "$DIR"
backup_if_exists "$DIR/docker-compose.yml"
cat > "$DIR/docker-compose.yml" << EOF cat > "$DIR/docker-compose.yml" << EOF
name: kyber-server name: kyber-server
services: services:
@@ -411,6 +412,7 @@ EOF
# Write .env with restricted permissions # Write .env with restricted permissions
# Values are single-quoted so special characters ($, !, &, etc.) are safe. # Values are single-quoted so special characters ($, !, &, etc.) are safe.
# Exception: single quotes inside a value would still break — avoid them. # Exception: single quotes inside a value would still break — avoid them.
backup_if_exists "$DIR/.env"
cat > "$DIR/.env" << EOF cat > "$DIR/.env" << EOF
MAXIMA_CREDENTIALS='${EA_EMAIL}:${EA_PASSWORD}' MAXIMA_CREDENTIALS='${EA_EMAIL}:${EA_PASSWORD}'
KYBER_TOKEN='${KYBER_TOKEN}' KYBER_TOKEN='${KYBER_TOKEN}'
@@ -558,6 +560,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
ACTUAL_USER="${SUDO_USER:-$USER}" ACTUAL_USER="${SUDO_USER:-$USER}"
ACTUAL_HOME=$(eval echo "~$ACTUAL_USER") ACTUAL_HOME=$(eval echo "~$ACTUAL_USER")
+7
View File
@@ -188,6 +188,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -353,6 +358,7 @@ install_lyrion() {
_HTTP_PORT_INTERNAL="9000" _HTTP_PORT_INTERNAL="9000"
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << LYRION_COMPOSE cat > docker-compose.yml << LYRION_COMPOSE
name: $CONTAINER name: $CONTAINER
@@ -375,6 +381,7 @@ ${_NETWORK_BLOCK}${_PORTS_BLOCK} environment:
LYRION_COMPOSE LYRION_COMPOSE
backup_if_exists .env
cat > .env << LYRION_ENV cat > .env << LYRION_ENV
MUSIC_PATH=$MUSIC_PATH MUSIC_PATH=$MUSIC_PATH
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
+6
View File
@@ -174,6 +174,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -254,6 +259,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << MM_COMPOSE cat > docker-compose.yml << MM_COMPOSE
name: mm-$MM_PORT name: mm-$MM_PORT
+7
View File
@@ -170,6 +170,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -244,6 +249,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << MA_COMPOSE cat > docker-compose.yml << MA_COMPOSE
name: mail-archiver name: mail-archiver
@@ -279,6 +285,7 @@ ${_CADDY_NET_BLOCK}
${_CADDY_NET_SECTION} ${_CADDY_NET_SECTION}
MA_COMPOSE MA_COMPOSE
backup_if_exists .env
cat > .env << MA_ENV cat > .env << MA_ENV
# ── General ─────────────────────────────────────────────────────────────────── # ── General ───────────────────────────────────────────────────────────────────
TZ=$TZ_VAL TZ=$TZ_VAL
+108
View File
@@ -0,0 +1,108 @@
## Android push notifications inconsistent after a migration (e.g. from PikaPods)
Symptom: "Enable Push Notifications" is on in System Console, but only some
Android users actually get background push notifications — one user gets
them reliably, others on the same server don't. Since this is per-device
rather than server-wide, work through these in order; the first that
reproduces the symptom is almost always the actual cause.
### 1. Rule out server → push-proxy connectivity first (quick, and if this is
broken it explains ALL users failing, not just some)
The mobile app talks to Google's FCM directly for the device token, but it's
*your* Mattermost server that calls out to the push relay (default
`https://push.mattermost.com`, System Console → Environment → Push
Notification Server) every time it needs to fan out a push. Confirm the new
VPS can actually reach it — a fresh box's outbound rules, or a NAT/firewall
inherited from the migration, can block this silently:
```bash
docker exec mattermost curl -Is https://push.mattermost.com | head -1
```
Then check the server's own logs for push attempts/failures:
```bash
docker compose logs mattermost | grep -i push
```
If specific users' pushes error out while others succeed, that already rules
out a global connectivity/config problem and points at something per-account
(section 2) or per-device (section 3).
### 2. Stale device registration carried over by the migration
A SQL dump import (`migrate-from-pikapods.sh` or any other DB restore) brings
the `Sessions` table with it — including each user's `DeviceId`, the
FCM token that was registered against the *old* server. That registration
only gets refreshed on a real login, not by the app quietly staying open:
a session that survived the move keeps working perfectly for live chat
(the websocket connection has nothing to do with push registration) while
its background push silently stops working, because the token behind it may
now be stale.
This matches "one user is always fine, everyone else isn't" almost exactly —
the working user is typically the one who happened to log out/in (or
reinstalled the app) since the migration, refreshing their `DeviceId`, while
everyone else's session rode through the import unchanged.
**Fix:** have affected users fully log out of the Mattermost Android app
(not just background it — Menu → Log Out) and log back in. This forces a
fresh device-token registration against the current server.
### 3. Android OEM battery optimization (the most common purely-device-side cause)
Xiaomi/MIUI, Huawei, Samsung, OnePlus, and Oppo/Vivo all ship aggressive
battery managers that kill background apps and their FCM listeners by
default — independent of anything about the server. This is the single most
common reason some Android phones on the exact same server get pushes and
others don't, migration or no migration. Have affected users check, per
device:
- **Settings → Apps → Mattermost → Battery** → set to "Unrestricted" / "No
restrictions" / disable "Battery Saver" for the app (menu wording varies
by OEM/Android version).
- **Notification permission itself** is still granted — Android 13+ requires
an explicit runtime permission that can get silently revoked (e.g. after
an OS update), separate from the app's own in-app notification settings.
- Some OEMs (Xiaomi especially) also gate this behind a separate
"Autostart" toggle for the app.
### 4. Push notification content setting, if section 1 and 2 don't explain it
System Console → Environment → Push Notification Server → **Push
Notification Contents**. If set to anything other than "Send full message
contents", the client has to phone the server's own `SiteURL` back for the
real content after getting the push shell — so if the *new* domain isn't
reliably reachable from a given user's network (split-horizon DNS, a mobile
carrier blocking something, a half-finished Caddy/DNS cutover for the new
VPS), that user can receive the push notification itself but never see
real content, or see it inconsistently. Temporarily switching to "Send full
message contents" removes this variable while narrowing down the cause.
### 5. A changed FQDN specifically — DNS propagation and cert readiness
If the migration also moved the server to a new domain (not just a new box
under the same domain), that alone can produce exactly this
some-users-fine/some-users-not pattern for a few days after cutover, on top
of section 2 and 4 above:
- **DNS propagation lags per device.** Different users' resolvers (ISP DNS,
carrier DNS on cellular, cached records with old TTLs) pick up the new
FQDN's IP at different times. A user on a fast public resolver sees it
immediately; someone on carrier DNS with a stale cache might not resolve
it correctly for hours. Combined with section 4 (anything but "full
message contents" requires a content-fetch call back to `SiteURL`), a
device with a stale answer for the new FQDN fails that step while others
succeed.
- **TLS certificate not fully issued/propagated yet** for the new FQDN
(Caddy/Let's Encrypt) causes the same content-fetch failure via cert
validation instead of DNS.
- **Confirm `MM_SERVICESETTINGS_SITEURL` in `.env` is actually the new FQDN**
— a value left over from before the domain change points every client's
content-fetch at the wrong place, consistently, not just intermittently.
- Users who never explicitly added the new server URL in the mobile app (a
kept redirect from the old domain let them keep working without
noticing) are still running on their old, stale device registration —
this is section 2's mechanism, just caused directly by the FQDN change
rather than by the DB import alone.
+7
View File
@@ -213,6 +213,11 @@ CBLOCK
[[ "${DRY_RUN:-false}" == "true" ]] && return 0 [[ "${DRY_RUN:-false}" == "true" ]] && return 0
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
generate_password() { generate_password() {
local _len="${1:-32}" local _len="${1:-32}"
@@ -518,6 +523,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << EOF cat > docker-compose.yml << EOF
name: ${PROJECT} name: ${PROJECT}
@@ -580,6 +586,7 @@ ${_CADDY_NET_BLOCK} healthcheck:
${_CADDY_NET_BLOCK}${_COTURN_SERVICE}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_COTURN_SERVICE}${_CADDY_NET_SECTION}
EOF EOF
backup_if_exists .env
cat > .env << EOF cat > .env << EOF
TZ=$TZ_VAL TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
+91 -3
View File
@@ -180,6 +180,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -219,7 +224,24 @@ _mealie_offer_authelia_oidc() {
[ -d "$DOCKER_DIR/authelia" ] || return 0 [ -d "$DOCKER_DIR/authelia" ] || return 0
declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0 declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0
grep -q '^OIDC_AUTH_ENABLED=' "$DIR/.env" 2>/dev/null && return 0
# Same reasoning as the equivalent check in services/actualbudget.sh: a
# silent `return 0` here is indistinguishable from this step not
# running at all. Always say something, and offer to redo it.
if grep -q '^OIDC_AUTH_ENABLED=' "$DIR/.env" 2>/dev/null; then
echo ""
log_info "Authelia SSO is already configured for Mealie (OIDC_* already set in $DIR/.env)."
local RECONFIGURE=""
prompt_yn " Reconfigure it (registers a fresh Authelia client + secret)? (y/n):" "n" RECONFIGURE
if [[ ! "$RECONFIGURE" =~ ^[Yy]$ ]]; then
_mealie_offer_disable_password_login "$DIR"
return 0
fi
# ALLOW_PASSWORD_LOGIN isn't OIDC_-prefixed but is written by this
# same step (see below) — strip it too so reconfiguring doesn't
# leave a stale duplicate line if it's set again.
sed -i '/^OIDC_/d; /^ALLOW_PASSWORD_LOGIN=/d' "$DIR/.env"
fi
local BASE_URL local BASE_URL
BASE_URL="$(grep '^BASE_URL=' "$DIR/.env" 2>/dev/null | cut -d= -f2-)" BASE_URL="$(grep '^BASE_URL=' "$DIR/.env" 2>/dev/null | cut -d= -f2-)"
@@ -237,12 +259,12 @@ _mealie_offer_authelia_oidc() {
prompt_yn " Require two-factor for Mealie logins via Authelia too? (y/n):" "y" _2fa prompt_yn " Require two-factor for Mealie logins via Authelia too? (y/n):" "y" _2fa
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor" [[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
if ! _authelia_provision_oidc_client "Mealie" "mealie" "$AUTH_POLICY" "y" "${BASE_URL}/login"; then if ! _authelia_provision_oidc_client "Mealie" "mealie" "$AUTH_POLICY" "y" "n" "" "${BASE_URL}/login"; then
log_warning "Couldn't register Mealie as an OIDC client in Authelia — skipping SSO setup." log_warning "Couldn't register Mealie as an OIDC client in Authelia — skipping SSO setup."
return 0 return 0
fi fi
local _discovery_url="https://auth.${OIDC_AUTHELIA_DOMAIN}/.well-known/openid-configuration" local _discovery_url="${OIDC_AUTHELIA_PORTAL_URL}/.well-known/openid-configuration"
cat >> "$DIR/.env" << ENV cat >> "$DIR/.env" << ENV
# Written by services/mealie.sh's Authelia SSO step — adds "Sign in with # Written by services/mealie.sh's Authelia SSO step — adds "Sign in with
@@ -273,6 +295,54 @@ ENV
|| log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs" || log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "mealie" "${BASE_URL#*://}" declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "mealie" "${BASE_URL#*://}"
echo ""
log_info "Test the \"Login with Authelia\" button on Mealie's own login page before"
log_info "disabling local login — re-run 'sudo ./setup.sh mealie' (choose update,"
log_info "then \"Reconfigure? n\") once you've confirmed it works, and you'll be"
log_info "offered that as a separate step."
}
# Split out from _mealie_offer_authelia_oidc so disabling local login is
# never offered in the same breath as first setting SSO up — confirmed
# live (on Beszel, same risk class) that saying yes before actually testing
# the Authelia button leaves both login paths broken at once. Only reached
# from a later "update" rerun once OIDC is already configured and the admin
# declines to reconfigure — i.e. after they've had a real chance to test it.
_mealie_offer_disable_password_login() {
local DIR="$1"
grep -q '^ALLOW_PASSWORD_LOGIN=false' "$DIR/.env" 2>/dev/null && return 0
echo ""
local _tested=""
prompt_yn " Have you ALREADY logged into Mealie successfully using the Authelia button (not just enabled it)? (y/n):" "n" _tested
if [[ ! "$_tested" =~ ^[Yy]$ ]]; then
log_info "Skipped. Test the Authelia login button first, then re-run 'sudo ./setup.sh mealie' (choose update) to come back to this."
return 0
fi
local _disable_local=""
prompt_yn " Also disable Mealie's own username/password login, so Authelia is the only way in? (y/n):" "n" _disable_local
[[ "$_disable_local" =~ ^[Yy]$ ]] || return 0
log_warning "Anyone without an Authelia account (only a local Mealie one) will no longer be able to log in."
log_info "Reversible any time: set ALLOW_PASSWORD_LOGIN back to true in $DIR/.env and 'docker compose up -d'."
local _auto_redirect=""
prompt_yn " Skip Mealie's login page entirely and jump straight to Authelia? (y/n):" "y" _auto_redirect
sed -i '/^ALLOW_PASSWORD_LOGIN=/d; /^OIDC_AUTO_REDIRECT=/d; /^OIDC_REMEMBER_ME=/d' "$DIR/.env"
{
echo "ALLOW_PASSWORD_LOGIN=false"
if [[ "$_auto_redirect" =~ ^[Yy]$ ]]; then
echo "OIDC_AUTO_REDIRECT=true"
echo "OIDC_REMEMBER_ME=true"
fi
} >> "$DIR/.env"
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/.env" 2>/dev/null || true
(cd "$DIR" && docker compose up -d) \
&& log_success "Local username/password login is now disabled — Authelia is the only way in." \
|| log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"
} }
install_mealie() { install_mealie() {
@@ -394,6 +464,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << MEALIE_COMPOSE cat > docker-compose.yml << MEALIE_COMPOSE
name: $CONTAINER name: $CONTAINER
@@ -418,6 +489,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
MEALIE_COMPOSE MEALIE_COMPOSE
backup_if_exists .env
cat > .env << MEALIE_ENV cat > .env << MEALIE_ENV
# Public URL Mealie is served on — used for email links and OAuth redirects. # Public URL Mealie is served on — used for email links and OAuth redirects.
# Update if you change your domain or switch from HTTP to HTTPS. # Update if you change your domain or switch from HTTP to HTTPS.
@@ -431,6 +503,22 @@ MEALIE_ENV
configure_caddy_for_service "Mealie${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:9000" "recipes${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" configure_caddy_for_service "Mealie${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:9000" "recipes${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
# The domain typed at that prompt can differ from the recipes.<domain>
# default BASE_URL was already set to above (e.g. the user overrides it
# with a different subdomain). Reconcile BASE_URL to match whatever
# Caddy actually ended up fronting, since BASE_URL is what gets
# registered as the OIDC redirect URI just below — a stale BASE_URL
# there means Authelia rejects every login with "redirect_uri does not
# match any of the OAuth 2.0 Client's pre-registered redirect_uris" even
# though Caddy and DNS both point at the right place. Confirmed live:
# this is exactly what happened when the Caddy prompt was answered with
# a different subdomain than the auto-generated default.
if [ "$CADDY_SERVICE_CONFIGURED" = true ] && [ -n "$CADDY_SERVICE_DOMAIN" ] && [ "$MEALIE_BASE_URL" != "https://${CADDY_SERVICE_DOMAIN}" ]; then
MEALIE_BASE_URL="https://${CADDY_SERVICE_DOMAIN}"
sed -i "s#^BASE_URL=.*#BASE_URL=${MEALIE_BASE_URL}#" .env
log_info "BASE_URL updated to match the domain just configured: $MEALIE_BASE_URL"
fi
declare -F _mealie_offer_authelia_oidc >/dev/null 2>&1 && _mealie_offer_authelia_oidc "$MEALIE_DIR" "$CONTAINER" declare -F _mealie_offer_authelia_oidc >/dev/null 2>&1 && _mealie_offer_authelia_oidc "$MEALIE_DIR" "$CONTAINER"
write_readme "$MEALIE_DIR" << MD write_readme "$MEALIE_DIR" << MD
+7
View File
@@ -188,6 +188,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -323,6 +328,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << MC_COMPOSE cat > docker-compose.yml << MC_COMPOSE
name: $CONTAINER name: $CONTAINER
@@ -350,6 +356,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
MC_COMPOSE MC_COMPOSE
backup_if_exists .env
cat > .env << MC_ENV cat > .env << MC_ENV
MC_HOSTNAME=$MC_HOSTNAME MC_HOSTNAME=$MC_HOSTNAME
MC_REVERSE_PROXY=false MC_REVERSE_PROXY=false
+2
View File
@@ -1519,6 +1519,7 @@ PREGENINFOEOF
- ./config:/data/config" - ./config:/data/config"
fi fi
backup_if_exists "$MC_DIR/docker-compose.yml"
cat > "$MC_DIR/docker-compose.yml" << COMPOSEEOF cat > "$MC_DIR/docker-compose.yml" << COMPOSEEOF
name: ${MC_NAME} name: ${MC_NAME}
@@ -1902,6 +1903,7 @@ EXPOSE 80
CLIENTDOCKEREOF CLIENTDOCKEREOF
# Standalone compose for the client-mods page (its own folder). # Standalone compose for the client-mods page (its own folder).
backup_if_exists "$CLIENT_MODS_DIR/docker-compose.yml"
cat > "$CLIENT_MODS_DIR/docker-compose.yml" << CMCOMPOSEEOF cat > "$CLIENT_MODS_DIR/docker-compose.yml" << CMCOMPOSEEOF
name: ${CM_NAME} name: ${CM_NAME}
+7
View File
@@ -177,6 +177,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -242,6 +247,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << N8N_COMPOSE cat > docker-compose.yml << N8N_COMPOSE
name: n8n name: n8n
@@ -266,6 +272,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
N8N_COMPOSE N8N_COMPOSE
backup_if_exists .env
cat > .env << N8N_ENV cat > .env << N8N_ENV
# n8n environment — edit before starting if needed # n8n environment — edit before starting if needed
N8N_HOST=n8n N8N_HOST=n8n
+7
View File
@@ -70,6 +70,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly # Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() { prompt_text() {
@@ -260,6 +265,7 @@ networks:
fi fi
# ── docker-compose.yml ────────────────────────────────────────────────── # ── docker-compose.yml ──────────────────────────────────────────────────
backup_if_exists docker-compose.yml
cat > docker-compose.yml << NCCOMPOSE cat > docker-compose.yml << NCCOMPOSE
name: nextcloud name: nextcloud
services: services:
@@ -290,6 +296,7 @@ ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
NCCOMPOSE NCCOMPOSE
# ── .env ──────────────────────────────────────────────────────────────── # ── .env ────────────────────────────────────────────────────────────────
backup_if_exists .env
cat > .env << NCENV cat > .env << NCENV
TZ=$TZ_VAL TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
+7
View File
@@ -184,6 +184,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -309,6 +314,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << NTFY_COMPOSE cat > docker-compose.yml << NTFY_COMPOSE
name: $CONTAINER name: $CONTAINER
@@ -329,6 +335,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
NTFY_COMPOSE NTFY_COMPOSE
backup_if_exists .env
cat > .env << NTFY_ENV cat > .env << NTFY_ENV
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)} TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
+7
View File
@@ -70,6 +70,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly # Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() { prompt_text() {
@@ -280,6 +285,7 @@ networks:
fi fi
# ── docker-compose.yml ────────────────────────────────────────────────── # ── docker-compose.yml ──────────────────────────────────────────────────
backup_if_exists docker-compose.yml
cat > docker-compose.yml << OOCOMPOSE cat > docker-compose.yml << OOCOMPOSE
name: onlyoffice name: onlyoffice
services: services:
@@ -299,6 +305,7 @@ ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
OOCOMPOSE OOCOMPOSE
# ── .env ──────────────────────────────────────────────────────────────── # ── .env ────────────────────────────────────────────────────────────────
backup_if_exists .env
cat > .env << OOENV cat > .env << OOENV
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
# JWT authentication — keep JWT_SECRET private # JWT authentication — keep JWT_SECRET private
+7
View File
@@ -210,6 +210,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -325,6 +330,7 @@ networks:
# example — NET_ADMIN specifically is only needed if this instance is # example — NET_ADMIN specifically is only needed if this instance is
# ever used as a DHCP server too, which it isn't here, but the other two # ever used as a DHCP server too, which it isn't here, but the other two
# (SYS_TIME, SYS_NICE) are part of that same documented baseline. # (SYS_TIME, SYS_NICE) are part of that same documented baseline.
backup_if_exists docker-compose.yml
cat > docker-compose.yml << PIHOLE_COMPOSE cat > docker-compose.yml << PIHOLE_COMPOSE
name: pihole name: pihole
@@ -351,6 +357,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
PIHOLE_COMPOSE PIHOLE_COMPOSE
backup_if_exists .env
cat > .env << PIHOLE_ENV cat > .env << PIHOLE_ENV
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)} TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
# Admin web UI password (System Console / login screen). # Admin web UI password (System Console / login screen).
+6
View File
@@ -177,6 +177,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -242,6 +247,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << PORTAINER_COMPOSE cat > docker-compose.yml << PORTAINER_COMPOSE
name: portainer name: portainer
+22 -6
View File
@@ -1436,7 +1436,7 @@ _pstn_check_killswitch_clear() {
# _pstn_install_periodic_timer above) ─────────────────────────────────────── # _pstn_install_periodic_timer above) ───────────────────────────────────────
_pstn_write_usage_alert_script() { _pstn_write_usage_alert_script() {
local FILE="$1" EA_DIR="$2" ASTERISK_DIR="$3" RATE="$4" MONTH_THRESHOLD="$5" \ local FILE="$1" EA_DIR="$2" ASTERISK_DIR="$3" RATE="$4" MONTH_THRESHOLD="$5" \
BURST_THRESHOLD="$6" MAX_MONTHLY_SPEND="$7" NTFY_URL="$8" CONTAINER_NAME="${9:-easy-asterisk}" BURST_THRESHOLD="$6" MAX_MONTHLY_SPEND="$7" NTFY_URL="$8" CONTAINER_NAME="${9:-asterisk}"
cat > "$FILE" << 'EOF' cat > "$FILE" << 'EOF'
#!/bin/bash #!/bin/bash
# Auto-generated by services/pstn-trunk.sh — do not edit directly, re-run # Auto-generated by services/pstn-trunk.sh — do not edit directly, re-run
@@ -1719,7 +1719,7 @@ _pstn_apply_settings() {
local EA_DIR="$1" ASTERISK_DIR="$2" local EA_DIR="$1" ASTERISK_DIR="$2"
local SERVER="$3" SERVER_IPS="$4" DID="$5" local SERVER="$3" SERVER_IPS="$4" DID="$5"
local RING_EXTS="$6" NTFY_URL="$7" RATE="$8" MONTH_THRESHOLD="$9" BURST_THRESHOLD="${10}" local RING_EXTS="$6" NTFY_URL="$7" RATE="$8" MONTH_THRESHOLD="$9" BURST_THRESHOLD="${10}"
local PROVIDER_NAME="${11}" MAX_MONTHLY_SPEND="${12:-0}" CONTAINER_NAME="${13:-easy-asterisk}" local PROVIDER_NAME="${11}" MAX_MONTHLY_SPEND="${12:-0}" CONTAINER_NAME="${13:-asterisk}"
_pstn_patch_vendor_files "$EA_DIR" || return 1 _pstn_patch_vendor_files "$EA_DIR" || return 1
@@ -1746,6 +1746,7 @@ _pstn_apply_settings() {
# Direct") — unquoted, bash's `source` would treat the second word of # Direct") — unquoted, bash's `source` would treat the second word of
# any such value as a command to run ("Direct: command not found"), # any such value as a command to run ("Direct: command not found"),
# confirmed live while testing the multi-IP change. # confirmed live while testing the multi-IP change.
backup_if_exists "$EA_DIR/.pstn-trunk.env"
cat > "$EA_DIR/.pstn-trunk.env" << ENV cat > "$EA_DIR/.pstn-trunk.env" << ENV
PROVIDER_NAME="${PROVIDER_NAME}" PROVIDER_NAME="${PROVIDER_NAME}"
TRUNK_SERVER="${SERVER}" TRUNK_SERVER="${SERVER}"
@@ -1879,8 +1880,18 @@ install_pstn-trunk() {
local KILLSWITCH_FILE="$ASTERISK_DIR/pstn-trunk-killswitch.conf" local KILLSWITCH_FILE="$ASTERISK_DIR/pstn-trunk-killswitch.conf"
local PERSONAL_DIDS_FILE="$ASTERISK_DIR/pstn-personal-dids.conf" local PERSONAL_DIDS_FILE="$ASTERISK_DIR/pstn-personal-dids.conf"
local SETTINGS_FILE="$EA_DIR/.pstn-trunk.env" local SETTINGS_FILE="$EA_DIR/.pstn-trunk.env"
local CONTAINER_NAME="easy-asterisk" # Read the box's own container_name instead of assuming — new installs
[[ "$ASTERISK_KIND" == "asterisk-digital-ocean" ]] && CONTAINER_NAME="easy-asterisk-do" # use plain "asterisk" now, but an existing "easy-asterisk" install
# (this repo's container name before that rename) keeps working
# unchanged until someone deliberately migrates it. See
# services/asterisk.sh's _asterisk_resolve_layout for the reasoning.
local CONTAINER_NAME="asterisk"
if [[ "$ASTERISK_KIND" == "asterisk-digital-ocean" ]]; then
CONTAINER_NAME="easy-asterisk-do"
elif [[ -f "$EA_DIR/docker-compose.yml" ]]; then
CONTAINER_NAME="$(grep -m1 '^[[:space:]]*container_name:' "$EA_DIR/docker-compose.yml" | awk '{print $2}')"
[[ -z "$CONTAINER_NAME" ]] && CONTAINER_NAME="asterisk"
fi
if [ "$DRY_RUN" = true ]; then if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would require an existing asterisk install (droplet or home/LAN)" echo "[DRY-RUN] Would require an existing asterisk install (droplet or home/LAN)"
@@ -1988,8 +1999,13 @@ install_pstn-trunk() {
# never picking up dialplan changes at all. Re-assert the fresh # never picking up dialplan changes at all. Re-assert the fresh
# detection here, discarding the sourced value, so it can't drift # detection here, discarding the sourced value, so it can't drift
# from reality and self-heals the persisted file too. # from reality and self-heals the persisted file too.
CONTAINER_NAME="easy-asterisk" CONTAINER_NAME="asterisk"
[[ "$ASTERISK_KIND" == "asterisk-digital-ocean" ]] && CONTAINER_NAME="easy-asterisk-do" if [[ "$ASTERISK_KIND" == "asterisk-digital-ocean" ]]; then
CONTAINER_NAME="easy-asterisk-do"
elif [[ -f "$EA_DIR/docker-compose.yml" ]]; then
CONTAINER_NAME="$(grep -m1 '^[[:space:]]*container_name:' "$EA_DIR/docker-compose.yml" | awk '{print $2}')"
[[ -z "$CONTAINER_NAME" ]] && CONTAINER_NAME="asterisk"
fi
_pstn_check_killswitch_clear "$ASTERISK_DIR" _pstn_check_killswitch_clear "$ASTERISK_DIR"
_pstn_apply_settings "$EA_DIR" "$ASTERISK_DIR" \ _pstn_apply_settings "$EA_DIR" "$ASTERISK_DIR" \
"$TRUNK_SERVER" "${TRUNK_SERVER_IPS:-}" "$TRUNK_DID" \ "$TRUNK_SERVER" "${TRUNK_SERVER_IPS:-}" "$TRUNK_DID" \
+7
View File
@@ -91,6 +91,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -230,6 +235,7 @@ install_rustdesk() {
prompt_yn "Require encrypted connections only? (recommended) (y/n):" "y" _enc prompt_yn "Require encrypted connections only? (recommended) (y/n):" "y" _enc
[ "$_enc" = "n" ] || [ "$_enc" = "N" ] && ENCRYPTED_ONLY="0" [ "$_enc" = "n" ] || [ "$_enc" = "N" ] && ENCRYPTED_ONLY="0"
backup_if_exists docker-compose.yml
cat > docker-compose.yml << RD_COMPOSE cat > docker-compose.yml << RD_COMPOSE
name: $CONTAINER name: $CONTAINER
@@ -251,6 +257,7 @@ services:
- ./rustdesk_data:/data - ./rustdesk_data:/data
RD_COMPOSE RD_COMPOSE
backup_if_exists .env
cat > .env << RD_ENV cat > .env << RD_ENV
# ── General ─────────────────────────────────────────────────────────────────── # ── General ───────────────────────────────────────────────────────────────────
TZ=$TZ_VAL TZ=$TZ_VAL
+214 -10
View File
@@ -105,7 +105,15 @@ install_security-dashboard() {
if [[ "$ASTERISK_EA_DIR" == *asterisk-digital-ocean ]]; then if [[ "$ASTERISK_EA_DIR" == *asterisk-digital-ocean ]]; then
ASTERISK_EA_CONTAINER="easy-asterisk-do" ASTERISK_EA_CONTAINER="easy-asterisk-do"
elif [ -n "$ASTERISK_EA_DIR" ]; then elif [ -n "$ASTERISK_EA_DIR" ]; then
ASTERISK_EA_CONTAINER="easy-asterisk" # Read the box's own container_name instead of assuming — new
# installs use plain "asterisk" now, but an existing "easy-asterisk"
# install (this repo's container name before that rename) keeps
# working unchanged until someone deliberately migrates it. See
# services/asterisk.sh's _asterisk_resolve_layout for the reasoning.
if [[ -f "$ASTERISK_EA_DIR/docker-compose.yml" ]]; then
ASTERISK_EA_CONTAINER="$(grep -m1 '^[[:space:]]*container_name:' "$ASTERISK_EA_DIR/docker-compose.yml" | awk '{print $2}')"
fi
[[ -z "$ASTERISK_EA_CONTAINER" ]] && ASTERISK_EA_CONTAINER="asterisk"
fi fi
echo "" echo ""
@@ -171,7 +179,7 @@ install_security-dashboard() {
prompt_yn "Reconfigure this dashboard's Caddy protection (Authelia domain, or add/rotate an independent Basic Auth layer)? (y/n):" "n" _reconf prompt_yn "Reconfigure this dashboard's Caddy protection (Authelia domain, or add/rotate an independent Basic Auth layer)? (y/n):" "n" _reconf
if [[ "$_reconf" =~ ^[Yy]$ ]]; then if [[ "$_reconf" =~ ^[Yy]$ ]]; then
_secdash_remove_caddy_block "$DASHBOARD_PORT" _secdash_remove_caddy_block "$DASHBOARD_PORT"
_secdash_configure_caddy "$DASHBOARD_PORT" _secdash_configure_caddy "$DASHBOARD_PORT" "$ASTERISK_EA_DIR" "$ASTERISK_EA_CONTAINER"
fi fi
echo "" echo ""
@@ -253,7 +261,7 @@ install_security-dashboard() {
# below already does this (line ~173); a fresh install needs the same # below already does this (line ~173); a fresh install needs the same
# removal, not just the same write. No-ops if nothing is deployed yet. # removal, not just the same write. No-ops if nothing is deployed yet.
_secdash_remove_caddy_block "$DASHBOARD_PORT" _secdash_remove_caddy_block "$DASHBOARD_PORT"
_secdash_configure_caddy "$DASHBOARD_PORT" _secdash_configure_caddy "$DASHBOARD_PORT" "$ASTERISK_EA_DIR" "$ASTERISK_EA_CONTAINER"
_secdash_configure_admin_scoping "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT" _secdash_configure_admin_scoping "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT"
write_readme "$APP_DIR" << README_MD write_readme "$APP_DIR" << README_MD
@@ -678,7 +686,7 @@ SUDOERS
# retroactively) using the exact same code path as a fresh install, instead # retroactively) using the exact same code path as a fresh install, instead
# of hand-patching a live Caddyfile block in place. # of hand-patching a live Caddyfile block in place.
_secdash_configure_caddy() { _secdash_configure_caddy() {
local DASHBOARD_PORT="$1" local DASHBOARD_PORT="$1" ASTERISK_EA_DIR="${2:-}" ASTERISK_EA_CONTAINER="${3:-}"
echo "" echo ""
if ! command -v docker &>/dev/null || ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^caddy$"; then if ! command -v docker &>/dev/null || ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^caddy$"; then
@@ -686,12 +694,17 @@ _secdash_configure_caddy() {
return 0 return 0
fi fi
local _default_domain=""
if [ -n "${SITE_DOMAIN:-}" ] && [ "$SITE_DOMAIN" != "example.com" ]; then
_default_domain="security.${SITE_DOMAIN}"
fi
local SD_DOMAIN="" local SD_DOMAIN=""
prompt_text " Domain for the dashboard (e.g. security.yourdomain.com), you'll need to point DNS at this droplet yourself [${_default_domain:-required}]:" "$_default_domain" SD_DOMAIN _secdash_offer_asterisk_domain "$ASTERISK_EA_DIR" "$DASHBOARD_PORT" "$ASTERISK_EA_CONTAINER"
SD_DOMAIN="$ASTERISK_TAKEOVER_DOMAIN"
if [ -z "$SD_DOMAIN" ]; then
local _default_domain=""
if [ -n "${SITE_DOMAIN:-}" ] && [ "$SITE_DOMAIN" != "example.com" ]; then
_default_domain="security.${SITE_DOMAIN}"
fi
prompt_text " Domain for the dashboard (e.g. security.yourdomain.com), you'll need to point DNS at this droplet yourself [${_default_domain:-required}]:" "$_default_domain" SD_DOMAIN
fi
if [ -z "$SD_DOMAIN" ]; then if [ -z "$SD_DOMAIN" ]; then
log_warning "No domain entered — dashboard stays on http://localhost:$DASHBOARD_PORT only (not reachable from outside this box)." log_warning "No domain entered — dashboard stays on http://localhost:$DASHBOARD_PORT only (not reachable from outside this box)."
@@ -801,6 +814,20 @@ CADDYBLOCK
log_warning "$SD_DOMAIN already in Caddyfile — leaving the existing entry alone." log_warning "$SD_DOMAIN already in Caddyfile — leaving the existing entry alone."
fi fi
# Offer per-user access scoping now that this domain is actually
# Authelia-protected (local import or remote forward_auth — EXTRA_BLOCK
# is only non-empty when one of those was configured above; skip this
# entirely for Basic-Auth-only or no-auth setups, where there's no
# Authelia gate to scope). This dashboard was never wired into
# _authelia_scope_access before now, on either this domain-takeover path
# or the plain separate-subdomain path below it — every protected
# domain here defaulted to "any Authelia user", with no way to restrict
# it to specific people. Guarded by declare -F: this file can run
# standalone with authelia.sh never sourced.
if [ -n "$EXTRA_BLOCK" ] && declare -F _authelia_scope_access >/dev/null 2>&1; then
_authelia_scope_access "security-dashboard" "$SD_DOMAIN"
fi
# This port never needs to be open to the internet — only Caddy (local, # This port never needs to be open to the internet — only Caddy (local,
# via host.docker.internal) ever needs to reach it. # via host.docker.internal) ever needs to reach it.
if command -v ufw &>/dev/null; then if command -v ufw &>/dev/null; then
@@ -996,6 +1023,100 @@ _secdash_remove_caddy_block() {
log_info "Removed the existing dashboard Caddy block (regenerating it fresh)." log_info "Removed the existing dashboard Caddy block (regenerating it fresh)."
} }
# Same technique as _secdash_remove_caddy_block above, but keyed on the
# block's own opening "<domain> {" line instead of a reverse_proxy marker —
# used by _secdash_offer_asterisk_domain to remove ASTERISK'S OLD block for
# a domain it's handing over, not this dashboard's own.
_secdash_remove_caddy_block_by_domain() {
local domain="$1"
local caddy_file="$DOCKER_DIR/caddy/Caddyfile"
[ -f "$caddy_file" ] || return 0
local domain_line end_line start_line
domain_line="$(grep -nx "${domain} {" "$caddy_file" | head -1 | cut -d: -f1)"
[ -z "$domain_line" ] && return 0 # nothing there — fine
# Pull in a "# <comment>" line directly above it too, if present (every
# site block this repo writes has one, e.g. "# Asterisk Web Admin").
start_line="$domain_line"
if [ "$domain_line" -gt 1 ] && sed -n "$((domain_line - 1))p" "$caddy_file" | grep -qE '^# '; then
start_line=$((domain_line - 1))
fi
end_line="$(tail -n "+$domain_line" "$caddy_file" | grep -nx '}' | head -1 | cut -d: -f1)"
if [ -z "$end_line" ]; then
log_warning "Could not find the end of ${domain}'s existing Caddy block — leaving it as-is."
return 1
fi
end_line=$((domain_line + end_line - 1))
sed -i "${start_line},${end_line}d" "$caddy_file"
log_info "Removed the existing Caddy block for ${domain} (regenerating it fresh)."
}
# ── Optional: take over Asterisk's own public domain instead of a separate
# one ─────────────────────────────────────────────────────────────────────
# Asterisk's web admin is only Caddy-fronted at its own domain so Caddy can
# get it a trusted TLS cert for SIP (see _asterisk_configure_caddy_public in
# services/asterisk.sh) — cert issuance only needs Caddy to own that
# domain's site block and answer the ACME challenge there; it's unrelated to
# what reverse_proxy target the block actually forwards to (Asterisk's own
# cert-sync reads the issued cert straight off Caddy's disk storage, not by
# hitting the site). So nothing stops this dashboard from taking that domain
# over entirely instead of asking for its own — one less DNS entry/cert to
# manage, and it closes a real gap along the way: _asterisk_configure_caddy_public
# never rewrites an existing site block on a repeat run, it just leaves an
# already-present domain line alone. That means a box where
# WEB_ADMIN_AUTH_DISABLED got set true (built-in login turned off, from an
# earlier "protect with Authelia" answer) but the Authelia import itself
# never landed or got lost (e.g. on a restore that didn't carry the Caddyfile
# edit) is stuck silently unauthenticated with no reconfigure path ever
# revisiting it — confirmed live (2026-08-22): a real box was found exposing
# its extensions/device list with no login at all.
#
# Sets ASTERISK_TAKEOVER_DOMAIN (non-local out-param, same convention as
# lib/common.sh's configure_caddy_for_service CADDY_SERVICE_* out-params) to
# the domain taken over, or leaves it empty if there's nothing to offer or
# the offer was declined — the caller falls back to its normal own-domain
# prompt in that case.
_secdash_offer_asterisk_domain() {
local ea_dir="$1" dashboard_port="$2" ea_container="$3"
ASTERISK_TAKEOVER_DOMAIN=""
[ -n "$ea_dir" ] && [ -f "$ea_dir/.env" ] || return 0
local _domain
_domain="$(grep -E '^DOMAIN_NAME=' "$ea_dir/.env" | cut -d= -f2-)"
[ -n "$_domain" ] || return 0
local _caddy_file="$DOCKER_DIR/caddy/Caddyfile"
[ -f "$_caddy_file" ] && grep -qx "${_domain} {" "$_caddy_file" || return 0
echo ""
log_info "Asterisk already has a public domain: ${_domain} (currently serving its own web"
log_info "admin there, kept only so Caddy can get it a trusted TLS cert for SIP)."
local _takeover=""
prompt_yn " Serve this dashboard there instead, and stop exposing Asterisk's own web admin publicly? (y/n):" "n" _takeover
[[ "$_takeover" =~ ^[Yy]$ ]] || return 0
_secdash_remove_caddy_block_by_domain "$_domain"
_secdash_remove_caddy_block "$dashboard_port"
if grep -q '^WEB_ADMIN_AUTH_DISABLED=' "$ea_dir/.env"; then
sed -i 's/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=false/' "$ea_dir/.env"
# Not restarting Asterisk here — this is a defense-in-depth measure
# on a port that's no longer published at all now that the Caddy
# block above it is gone, not the live exposure fix (that's already
# done by removing the block). Not worth interrupting an active
# call for; takes effect on Asterisk's next restart either way.
log_info "Re-enabled Asterisk's own web admin login in .env (defense-in-depth; takes effect on"
log_info "Asterisk's next restart — not forcing one now in case a call is active)."
fi
log_warning "Asterisk's native web admin is no longer reachable over HTTPS — use the terminal instead:"
log_warning " docker exec -it ${ea_container:-asterisk} easy-asterisk"
ASTERISK_TAKEOVER_DOMAIN="$_domain"
}
# Full teardown for "Full reinstall" — stops the service and removes # Full teardown for "Full reinstall" — stops the service and removes
# everything a fresh install recreates: systemd unit, sudoers grant, Caddy # everything a fresh install recreates: systemd unit, sudoers grant, Caddy
# site block, the secdash system user, and the app directory. Non-Docker # site block, the secdash system user, and the app directory. Non-Docker
@@ -1956,6 +2077,79 @@ def ea_reload_voicemail():
run_sudo(["docker", "exec", ASTERISK_EA_CONTAINER, "asterisk", "-rx", "module reload app_voicemail.so"]) run_sudo(["docker", "exec", ASTERISK_EA_CONTAINER, "asterisk", "-rx", "module reload app_voicemail.so"])
def _ea_endpoint_stanza_bounds(lines, ext):
"""Line-index range (start, end-exclusive) of the `[ext]\\ntype=endpoint`
PJSIP stanza for one extension, or None if not found. pjsip.conf reuses
the same [ext] bracket name for three separate stanzas per device
(type=endpoint, type=auth, type=aor — see easy-asterisk-v0.10.0.sh's
add_device()), so matching on the bracket alone would land in the wrong
one; this only matches the occurrence immediately followed by
"type=endpoint", bounded by the next blank line or next [section] the
same way lib/common.sh's _remove_caddy_site_block is bounded for Caddy
blocks — never an unbounded scan past this one device's own stanza."""
target = "[%s]" % ext
i, n = 0, len(lines)
while i < n:
if lines[i].strip() == target and i + 1 < n and lines[i + 1].strip() == "type=endpoint":
j = i + 1
while j < n and lines[j].strip() != "" and not lines[j].strip().startswith("["):
j += 1
return i, j
i += 1
return None
def _ea_set_endpoint_mailboxes(ext, enabled):
"""Adds/updates (enabled) or removes (disabled) the extension's PJSIP
`mailboxes=` line, so a phone can actually SUBSCRIBE for MWI (the "new
voicemail" notice) on this extension.
Confirmed live: nothing anywhere in this repo or the vendored
easy-asterisk script ever sets this. add_device()'s own device_config
template (easy-asterisk-v0.10.0.sh) never writes it, and until this,
write_voicemail() below 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. `mailboxes=<ext>@default`
matches the "default" context name voicemail.conf's [default] section
uses (see _asterisk_write_voicemail_conf in services/asterisk.sh)
same context, just referenced from the endpoint side instead of the
dialplan side."""
path = _ea_pjsip_host_path()
if not path or not os.path.isfile(path):
return False, "No pjsip.conf found"
with open(path) as f:
lines = f.readlines()
bounds = _ea_endpoint_stanza_bounds(lines, ext)
if not bounds:
return False, "No PJSIP endpoint found for extension %s" % ext
start, end = bounds
existing_idx = None
for k in range(start, end):
if lines[k].lstrip().startswith("mailboxes="):
existing_idx = k
break
if enabled:
mailbox_line = "mailboxes=%s@default\n" % ext
if existing_idx is not None:
lines[existing_idx] = mailbox_line
else:
lines.insert(end, mailbox_line)
elif existing_idx is not None:
del lines[existing_idx]
else:
return True, ""
ok, err = ea_docker_write(EA_PJSIP_CONTAINER_PATH, "".join(lines))
if not ok:
return False, err
ea_reload_pjsip()
return True, ""
def write_voicemail(ext, enabled): def write_voicemail(ext, enabled):
"""Sets/clears the voicemail flag for one extension, then regenerates """Sets/clears the voicemail flag for one extension, then regenerates
voicemail.conf and reloads app_voicemail so the change takes effect voicemail.conf and reloads app_voicemail so the change takes effect
@@ -1968,7 +2162,12 @@ def write_voicemail(ext, enabled):
pstn-permissions.conf even after disabling — toggling it off and back on pstn-permissions.conf even after disabling — toggling it off and back on
later reuses the same PIN instead of silently changing it on the user. later reuses the same PIN instead of silently changing it on the user.
Independent of pstn_installed() the same way messaging is: voicemail has Independent of pstn_installed() the same way messaging is: voicemail has
no PSTN/trunk dependency.""" no PSTN/trunk dependency.
Also wires up (or tears down) MWI via _ea_set_endpoint_mailboxes() — the
extension's PJSIP endpoint needs its own `mailboxes=` line for a phone
to ever be told about a new voicemail; voicemail.conf alone is only
enough for the recording itself, not the notification."""
if not ASTERISK_CONFIG_DIR: if not ASTERISK_CONFIG_DIR:
return False, "No Asterisk install detected on this box" return False, "No Asterisk install detected on this box"
ext = str(ext).strip() ext = str(ext).strip()
@@ -1990,6 +2189,11 @@ def write_voicemail(ext, enabled):
return True, "Saved, but voicemail.conf couldn't be regenerated: %s" % err return True, "Saved, but voicemail.conf couldn't be regenerated: %s" % err
ea_reload_voicemail() ea_reload_voicemail()
mok, merr = _ea_set_endpoint_mailboxes(ext, enabled)
if not mok:
return True, "Saved, but couldn't wire up the phone's voicemail notification (MWI): %s" % merr
return True, "Saved" return True, "Saved"
+6
View File
@@ -60,6 +60,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -176,6 +181,7 @@ EOF
prompt_text " WEB_EXT_API_KEY (e.g. user:12345678:901), blank to skip:" "" FF_KEY prompt_text " WEB_EXT_API_KEY (e.g. user:12345678:901), blank to skip:" "" FF_KEY
prompt_text " WEB_EXT_API_SECRET, blank to skip:" "" FF_SECRET prompt_text " WEB_EXT_API_SECRET, blank to skip:" "" FF_SECRET
if [ -n "$FF_KEY" ] && [ -n "$FF_SECRET" ]; then if [ -n "$FF_KEY" ] && [ -n "$FF_SECRET" ]; then
backup_if_exists "$SS_DIR/.env"
cat > "$SS_DIR/.env" << ENVEOF cat > "$SS_DIR/.env" << ENVEOF
WEB_EXT_API_KEY="$FF_KEY" WEB_EXT_API_KEY="$FF_KEY"
WEB_EXT_API_SECRET="$FF_SECRET" WEB_EXT_API_SECRET="$FF_SECRET"
+62 -15
View File
@@ -53,7 +53,15 @@ _sms_detect_container_name() {
if [[ "$_ea_dir" == *asterisk-digital-ocean ]]; then if [[ "$_ea_dir" == *asterisk-digital-ocean ]]; then
echo "easy-asterisk-do" echo "easy-asterisk-do"
else else
echo "easy-asterisk" # Read the box's own container_name instead of assuming — new
# installs use plain "asterisk" now, but an existing "easy-asterisk"
# install (this repo's container name before that rename) keeps
# working unchanged until someone deliberately migrates it. See
# services/asterisk.sh's _asterisk_resolve_layout for the reasoning.
local _name=""
[[ -f "$_ea_dir/docker-compose.yml" ]] && \
_name="$(grep -m1 '^[[:space:]]*container_name:' "$_ea_dir/docker-compose.yml" | awk '{print $2}')"
echo "${_name:-asterisk}"
fi fi
} }
@@ -629,17 +637,11 @@ CBLOCK
_sms_write_readme() { _sms_write_readme() {
local _url="$1" _relay_domain="$2" local _url="$1" _relay_domain="$2"
write_readme "$SMS_APP_DIR" << MD
# Inbound SMS → Sipnetic (via AMI)
Gets SMS sent to one of your PSTN DIDs delivered into Asterisk as a SIP local _url_section
MESSAGE, landing in Sipnetic the same way internal texting already does — if [ -n "$_url" ]; then
not a push notification, a real message in the softphone. _url_section="In the provider portal, open the DID's SMS settings and paste this into the
\"Forward to URL\" field (on Anveo: Phone Numbers → the DID → SMS tab, tick
## The URL to paste into your DID provider
In the provider portal, open the DID's SMS settings and paste this into the
"Forward to URL" field (on Anveo: Phone Numbers → the DID → SMS tab, tick
the checkbox, paste, press SAVE — RETURN discards): the checkbox, paste, press SAVE — RETURN discards):
\`\`\` \`\`\`
@@ -652,7 +654,21 @@ query parameters; with the message last, everything after it can be read back
verbatim. verbatim.
Treat this URL like a password — anyone holding it can trigger a message Treat this URL like a password — anyone holding it can trigger a message
delivery into your Asterisk. delivery into your Asterisk."
else
_url_section="**Not set up yet — no public domain was entered.** Re-run \`sudo ./setup.sh sms-inbound\` and choose \"Full reinstall\" once DNS for the webhook's domain points at this box; nothing here works until then."
fi
write_readme "$SMS_APP_DIR" << MD
# Inbound SMS → Sipnetic (via AMI)
Gets SMS sent to one of your PSTN DIDs delivered into Asterisk as a SIP
MESSAGE, landing in Sipnetic the same way internal texting already does —
not a push notification, a real message in the softphone.
## The URL to paste into your DID provider
${_url_section}
## How delivery is decided ## How delivery is decided
@@ -770,8 +786,24 @@ install_sms-inbound() {
&& log_success "Relay refreshed and restarted." \ && log_success "Relay refreshed and restarted." \
|| log_warning "Restart failed — check: journalctl -u sms-inbound -n 50" || log_warning "Restart failed — check: journalctl -u sms-inbound -n 50"
echo "" echo ""
log_success "Settings, Caddy and firewall rules were left untouched." # A missing/placeholder domain here means an earlier run was
echo " Provider URL: ${SMS_FORWARD_URL}" # left with no real webhook URL (RELAY_DOMAIN entered blank,
# or DNS wasn't ready yet) — "update" mode never re-prompts
# for the domain (by design, same as every other service's
# non-destructive update path), so silently repeating that
# broken URL forever, looking like nothing is wrong, is worse
# than saying so plainly. Confirmed live: this is exactly
# what a DID provider like Anveo rejects — "<your-domain>"
# isn't a resolvable hostname.
if [[ -z "${SMS_RELAY_DOMAIN:-}" || "${SMS_FORWARD_URL:-}" == *"<your-domain>"* ]]; then
log_warning "No real webhook domain was ever set for this install — the stored"
log_warning "provider URL is a placeholder, not something a DID provider can use."
log_warning "Re-run 'sudo ./setup.sh sms-inbound' and choose \"2) Full reinstall\""
log_warning "to be asked for the domain again (needs DNS pointed at this box first)."
else
log_success "Settings, Caddy and firewall rules were left untouched."
echo " Provider URL: ${SMS_FORWARD_URL}"
fi
echo "" echo ""
return 0 return 0
;; ;;
@@ -898,7 +930,14 @@ install_sms-inbound() {
ensure_ufw_enabled ensure_ufw_enabled
fi fi
local FORWARD_URL="https://${RELAY_DOMAIN:-<your-domain>}/sms/${RELAY_TOKEN}?from=\$[from]\$&to=\$[to]\$&message=\$[message]\$" # Empty (not a "<your-domain>" placeholder) when no domain was entered —
# a placeholder here used to get persisted to settings.env and silently
# re-served as-is on every later "update" run (which never re-prompts
# for the domain, by design), looking like a valid webhook URL right up
# until a DID provider like Anveo rejected it as an unresolvable host.
# Confirmed live.
local FORWARD_URL=""
[ -n "$RELAY_DOMAIN" ] && FORWARD_URL="https://${RELAY_DOMAIN}/sms/${RELAY_TOKEN}?from=\$[from]\$&to=\$[to]\$&message=\$[message]\$"
# ── Persist settings ────────────────────────────────────────────────────── # ── Persist settings ──────────────────────────────────────────────────────
# Single-quoted values: this file gets `source`d again on the next # Single-quoted values: this file gets `source`d again on the next
@@ -929,6 +968,14 @@ ENV
# ── Summary ─────────────────────────────────────────────────────────────── # ── Summary ───────────────────────────────────────────────────────────────
echo "" echo ""
if [ -z "$FORWARD_URL" ]; then
log_warning "Inbound SMS relay is running, but nothing can reach it yet — no domain was entered."
log_warning "Point an A record at this box, then re-run 'sudo ./setup.sh sms-inbound' and"
log_warning "choose \"2) Full reinstall\" to be asked for the domain again and get a real"
log_warning "\"Forward to URL\" to paste into your DID provider."
echo ""
return 0
fi
log_success "Inbound SMS → Sipnetic configured." log_success "Inbound SMS → Sipnetic configured."
echo "" echo ""
echo " 1. In your DID provider's portal, open the number's SMS settings and" echo " 1. In your DID provider's portal, open the number's SMS settings and"
+7
View File
@@ -177,6 +177,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -242,6 +247,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << PDF_COMPOSE cat > docker-compose.yml << PDF_COMPOSE
name: stirling-pdf name: stirling-pdf
@@ -261,6 +267,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
PDF_COMPOSE PDF_COMPOSE
backup_if_exists .env
cat > .env << PDF_ENV cat > .env << PDF_ENV
# Stirling PDF configuration # Stirling PDF configuration
+7
View File
@@ -147,6 +147,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -225,6 +230,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << EOF cat > docker-compose.yml << EOF
name: syncthing name: syncthing
@@ -249,6 +255,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
EOF EOF
backup_if_exists .env
cat > .env << ENV cat > .env << ENV
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
PUID=$PUID PUID=$PUID
+7
View File
@@ -180,6 +180,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -474,6 +479,7 @@ networks:
- \"${PROTO_MIN}-${PROTO_MAX}:${PROTO_MIN}-${PROTO_MAX}/udp\"" - \"${PROTO_MIN}-${PROTO_MAX}:${PROTO_MIN}-${PROTO_MAX}/udp\""
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << TRACCAR_COMPOSE cat > docker-compose.yml << TRACCAR_COMPOSE
name: $CONTAINER name: $CONTAINER
@@ -560,6 +566,7 @@ SMS_HTTP_PASSWORD=$SMS_HTTP_PASSWORD
fi fi
fi fi
backup_if_exists .env
cat > .env << TRACCAR_ENV cat > .env << TRACCAR_ENV
TZ=$TZ_VAL TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
+7
View File
@@ -89,6 +89,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -238,6 +243,7 @@ networks:
fi fi
# Unquoted heredoc; ${...} used for caddy_net vars; all Docker Compose vars escaped with \$ # Unquoted heredoc; ${...} used for caddy_net vars; all Docker Compose vars escaped with \$
backup_if_exists docker-compose.yml
cat > docker-compose.yml << UNIFI_COMPOSE cat > docker-compose.yml << UNIFI_COMPOSE
name: $PROJECT name: $PROJECT
@@ -286,6 +292,7 @@ configs:
db.getSiblingDB("\${MONGO_DBNAME}_stat").createUser({user: "\${MONGO_USER}", pwd: "\${MONGO_PASS}", roles: [{role: "\${MONGO_ROLE}", db: "\${MONGO_DBNAME}_stat"}]}); db.getSiblingDB("\${MONGO_DBNAME}_stat").createUser({user: "\${MONGO_USER}", pwd: "\${MONGO_PASS}", roles: [{role: "\${MONGO_ROLE}", db: "\${MONGO_DBNAME}_stat"}]});
UNIFI_COMPOSE UNIFI_COMPOSE
backup_if_exists .env
cat > .env << UNIFI_ENV cat > .env << UNIFI_ENV
# ── General ─────────────────────────────────────────────────────────────────── # ── General ───────────────────────────────────────────────────────────────────
TZ=$TZ_VAL TZ=$TZ_VAL
+57 -3
View File
@@ -177,6 +177,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -206,6 +211,9 @@ install_uptimekuma() {
if [ "$DRY_RUN" = true ]; then if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $UPTIME_DIR" echo "[DRY-RUN] Would create $UPTIME_DIR"
echo "[DRY-RUN] Would auto-scan for a free host port" echo "[DRY-RUN] Would auto-scan for a free host port"
echo "[DRY-RUN] If Authelia is installed: would offer to protect Uptime Kuma with it —"
echo "[DRY-RUN] sets DISABLE_AUTH=true (Kuma's own login off) only once Caddy's"
echo "[DRY-RUN] 'import authelia' gate is actually confirmed in front of it"
return 0 return 0
fi fi
@@ -241,6 +249,40 @@ networks:
" "
fi fi
# Authelia SSO — decided (and, if accepted, wired into Caddy) before
# docker-compose.yml is written, so DISABLE_AUTH only ever gets set once
# Caddy's "import authelia" gate is actually confirmed in front of Kuma.
# Unlike Frigate/Gitea, Uptime Kuma with DISABLE_AUTH=true has NO
# internal check left at all — it's not IP-scoped (Gitea) or secret-
# pinned (Frigate), just fully open to whatever reaches its port, so
# this is the one place getting the ordering wrong is worst: a login-
# disabled Kuma with nothing gating it is wide open to anyone who can
# reach the port, not just spoofable.
local UPTIME_USE_AUTHELIA="n" UPTIME_ENV_BLOCK="" _uptime_caddy_done=false
if [ -d "$DOCKER_DIR/authelia" ]; then
echo ""
prompt_yn "Protect Uptime Kuma with Authelia SSO (disables Kuma's own login entirely)? (y/n):" "y" UPTIME_USE_AUTHELIA
fi
if [[ "$UPTIME_USE_AUTHELIA" =~ ^[Yy]$ ]]; then
configure_caddy_for_service "Uptime Kuma" "uptime-kuma:3001" "uptime" " import authelia"
if [ "${CADDY_SERVICE_CONFIGURED:-false}" = true ]; then
UPTIME_ENV_BLOCK=" - DISABLE_AUTH=true"
_uptime_caddy_done=true
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "uptimekuma" "$CADDY_SERVICE_DOMAIN"
else
log_warning "Caddy wasn't configured — leaving Uptime Kuma's own login enabled (nothing else would be gating access)."
fi
fi
local UPTIME_ENV_SECTION=""
if [ -n "$UPTIME_ENV_BLOCK" ]; then
UPTIME_ENV_SECTION=" environment:
${UPTIME_ENV_BLOCK}
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << UPTIME_COMPOSE cat > docker-compose.yml << UPTIME_COMPOSE
name: uptime-kuma name: uptime-kuma
@@ -250,7 +292,7 @@ services:
container_name: uptime-kuma container_name: uptime-kuma
hostname: uptime-kuma hostname: uptime-kuma
restart: unless-stopped restart: unless-stopped
volumes: ${UPTIME_ENV_SECTION} volumes:
- ./data:/app/data - ./data:/app/data
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
ports: ports:
@@ -282,6 +324,15 @@ Docker containers.
If Caddy is installed, you can expose this via the prompt during install If Caddy is installed, you can expose this via the prompt during install
(see configure_caddy_for_service). Default subdomain: uptime. (see configure_caddy_for_service). Default subdomain: uptime.
## Authelia SSO (optional)
If Authelia is installed, the installer offers to protect Uptime Kuma with
it instead of Kuma's own login — this sets \`DISABLE_AUTH=true\` (Kuma's own
account/login screen goes away entirely) and puts Caddy's \`import authelia\`
gate in front instead, so Authelia is the only thing checking who you are.
This only gets set once Caddy confirms it's actually fronting the domain —
never with nothing else gating access. Re-run \`sudo ./setup.sh uptimekuma\`
to add or change this later.
## Manage ## Manage
\`\`\` \`\`\`
cd $UPTIME_DIR cd $UPTIME_DIR
@@ -291,8 +342,11 @@ docker compose logs -f # logs
\`\`\` \`\`\`
MD MD
# Configure Caddy reverse proxy before starting # Configure Caddy reverse proxy before starting (skip if the Authelia
configure_caddy_for_service "Uptime Kuma" "uptime-kuma:3001" "uptime" # step above already did it)
if [ "$_uptime_caddy_done" != true ]; then
configure_caddy_for_service "Uptime Kuma" "uptime-kuma:3001" "uptime"
fi
local START_UPTIME="" local START_UPTIME=""
prompt_yn "Start Uptime Kuma now? (y/n):" "y" START_UPTIME prompt_yn "Start Uptime Kuma now? (y/n):" "y" START_UPTIME
+34
View File
@@ -194,6 +194,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -225,6 +230,22 @@ register_service vaultwarden utilities "Bitwarden-compatible password manager (V
# live. Called right before every `docker compose up` this file does, not # live. Called right before every `docker compose up` this file does, not
# just at install time, so it self-heals regardless of how the box got into # just at install time, so it self-heals regardless of how the box got into
# this state. # this state.
# Vaultwarden requires DOMAIN to include an http(s):// scheme — a bare
# hostname crash-loops the container. Called at the same two sites as
# _vaultwarden_fix_smtp_halfstate() below, so a box whose .env got a
# scheme-less DOMAIN written before this fix existed (or hand-edited since)
# self-heals on its next start instead of staying stuck forever.
_vaultwarden_fix_domain_scheme() {
local env_file="$1"
[ -f "$env_file" ] || return 0
local domain
domain="$(grep '^DOMAIN=' "$env_file" 2>/dev/null | cut -d= -f2-)"
if [ -n "$domain" ] && [[ "$domain" != http://* && "$domain" != https://* ]]; then
log_warning "DOMAIN in $env_file is missing an http(s):// scheme ('$domain') — Vaultwarden requires one to start. Adding https:// automatically."
sed -i "s#^DOMAIN=.*#DOMAIN=https://${domain}#" "$env_file"
fi
}
_vaultwarden_fix_smtp_halfstate() { _vaultwarden_fix_smtp_halfstate() {
local env_file="$1" local env_file="$1"
[ -f "$env_file" ] || return 0 [ -f "$env_file" ] || return 0
@@ -298,6 +319,7 @@ install_vaultwarden() {
case "$MODE" in case "$MODE" in
update) update)
log_info "Refreshing the Vaultwarden image only — existing config, port, and Caddy setup are left as-is." log_info "Refreshing the Vaultwarden image only — existing config, port, and Caddy setup are left as-is."
_vaultwarden_fix_domain_scheme "$VW_DIR/.env"
_vaultwarden_fix_smtp_halfstate "$VW_DIR/.env" _vaultwarden_fix_smtp_halfstate "$VW_DIR/.env"
( cd "$VW_DIR" && docker compose pull && docker compose up -d ) \ ( cd "$VW_DIR" && docker compose pull && docker compose up -d ) \
&& log_success "Vaultwarden image refreshed" \ && log_success "Vaultwarden image refreshed" \
@@ -336,6 +358,15 @@ install_vaultwarden() {
local DEFAULT_DOMAIN="https://vault${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.${SITE_DOMAIN:-example.com}" local DEFAULT_DOMAIN="https://vault${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.${SITE_DOMAIN:-example.com}"
prompt_text "Vaultwarden public URL (e.g. https://vault.example.com):" "$DEFAULT_DOMAIN" VW_DOMAIN prompt_text "Vaultwarden public URL (e.g. https://vault.example.com):" "$DEFAULT_DOMAIN" VW_DOMAIN
[ -z "$VW_DOMAIN" ] && VW_DOMAIN="$DEFAULT_DOMAIN" [ -z "$VW_DOMAIN" ] && VW_DOMAIN="$DEFAULT_DOMAIN"
# Vaultwarden requires DOMAIN to include a URL scheme — a bare hostname
# (typing "vault.example.com" instead of "https://vault.example.com" at
# the prompt above, easy to do despite the example text showing the
# scheme) crash-loops the container with no clear startup error.
# Confirmed live. Normalize rather than trust free-form input.
if [[ "$VW_DOMAIN" != http://* && "$VW_DOMAIN" != https://* ]]; then
log_warning "No http(s):// scheme on '$VW_DOMAIN' — Vaultwarden requires one. Prefixing with https://."
VW_DOMAIN="https://$VW_DOMAIN"
fi
echo "" echo ""
echo " SMTP (optional) — for password-reset and invite emails." echo " SMTP (optional) — for password-reset and invite emails."
@@ -394,6 +425,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << VW_COMPOSE cat > docker-compose.yml << VW_COMPOSE
name: $CONTAINER name: $CONTAINER
@@ -411,6 +443,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
VW_COMPOSE VW_COMPOSE
backup_if_exists .env
cat > .env << VW_ENV cat > .env << VW_ENV
# ── General ─────────────────────────────────────────────────────────────────── # ── General ───────────────────────────────────────────────────────────────────
TZ=$TZ_VAL TZ=$TZ_VAL
@@ -487,6 +520,7 @@ MD
local START_VW="" local START_VW=""
prompt_yn "Start Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_VW prompt_yn "Start Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_VW
if [ "$START_VW" = "y" ] || [ "$START_VW" = "Y" ]; then if [ "$START_VW" = "y" ] || [ "$START_VW" = "Y" ]; then
_vaultwarden_fix_domain_scheme "$VW_DIR/.env"
_vaultwarden_fix_smtp_halfstate "$VW_DIR/.env" _vaultwarden_fix_smtp_halfstate "$VW_DIR/.env"
docker compose up -d \ docker compose up -d \
&& log_success "Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \ && log_success "Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \
+7
View File
@@ -64,6 +64,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -151,6 +156,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << WT_COMPOSE cat > docker-compose.yml << WT_COMPOSE
name: watchtower name: watchtower
@@ -179,6 +185,7 @@ ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
WT_COMPOSE WT_COMPOSE
# Create .env # Create .env
backup_if_exists .env
cat > .env << WT_ENV cat > .env << WT_ENV
# Watchtower Configuration # Watchtower Configuration
# ========================= # =========================
+7
View File
@@ -68,6 +68,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -126,6 +131,7 @@ install_watchyourlan() {
prompt_text "GUI port [8840]:" "8840" GUI_PORT prompt_text "GUI port [8840]:" "8840" GUI_PORT
[ -z "$GUI_PORT" ] && GUI_PORT="8840" [ -z "$GUI_PORT" ] && GUI_PORT="8840"
backup_if_exists docker-compose.yml
cat > docker-compose.yml << 'WYL_COMPOSE' cat > docker-compose.yml << 'WYL_COMPOSE'
name: watchyourlan name: watchyourlan
@@ -141,6 +147,7 @@ services:
- ./watchyourlan_data:/data - ./watchyourlan_data:/data
WYL_COMPOSE WYL_COMPOSE
backup_if_exists .env
cat > .env << WYL_ENV cat > .env << WYL_ENV
# ── General ─────────────────────────────────────────────────────────────────── # ── General ───────────────────────────────────────────────────────────────────
TZ=$TZ_VAL TZ=$TZ_VAL
+7
View File
@@ -199,6 +199,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -302,6 +307,7 @@ networks:
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << WGEASY_COMPOSE cat > docker-compose.yml << WGEASY_COMPOSE
name: wg-easy name: wg-easy
@@ -332,6 +338,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
WGEASY_COMPOSE WGEASY_COMPOSE
backup_if_exists .env
cat > .env << WGEASY_ENV cat > .env << WGEASY_ENV
WG_HOST=$WG_HOST WG_HOST=$WG_HOST
# Plain-text password — used only if PASSWORD_HASH could not be generated above # Plain-text password — used only if PASSWORD_HASH could not be generated above
+6
View File
@@ -169,6 +169,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -404,6 +409,7 @@ DOCKERFILE
# network_mode: host — server.py reaches Wolf at localhost:47989 directly. # network_mode: host — server.py reaches Wolf at localhost:47989 directly.
# Docker socket (ro) — server.py calls `docker logs wolf` to read secrets. # Docker socket (ro) — server.py calls `docker logs wolf` to read secrets.
log_info "Writing docker-compose.yml..." log_info "Writing docker-compose.yml..."
backup_if_exists "$WOLFPAIR_DIR/docker-compose.yml"
cat > "$WOLFPAIR_DIR/docker-compose.yml" << 'COMPOSE' cat > "$WOLFPAIR_DIR/docker-compose.yml" << 'COMPOSE'
name: wolf-pair name: wolf-pair
+7
View File
@@ -179,6 +179,11 @@ CBLOCK
mkdir -p "$_dir" mkdir -p "$_dir"
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -875,6 +880,7 @@ UDEV
# if the drive is remounted at a different path. IP/MAC/render-node are # if the drive is remounted at a different path. IP/MAC/render-node are
# baked in at install time because they're hardware-specific and not stored # baked in at install time because they're hardware-specific and not stored
# in .env — use 'manage.sh update-network' to regenerate if they change. # in .env — use 'manage.sh update-network' to regenerate if they change.
backup_if_exists docker-compose.yml
cat > docker-compose.yml << EOF cat > docker-compose.yml << EOF
name: wolf name: wolf
@@ -922,6 +928,7 @@ EOF
log_success "docker-compose.yml created" log_success "docker-compose.yml created"
# Save the game storage path so it's visible and editable later # Save the game storage path so it's visible and editable later
backup_if_exists .env
cat > .env << EOF cat > .env << EOF
# Wolf game/ROM storage root — edit this and run ./manage.sh update-storage to apply # Wolf game/ROM storage root — edit this and run ./manage.sh update-storage to apply
GAME_STORAGE_DIR=${GAME_STORAGE_DIR} GAME_STORAGE_DIR=${GAME_STORAGE_DIR}
+7
View File
@@ -207,6 +207,11 @@ CBLOCK
cat > "$_dir/README.md" cat > "$_dir/README.md"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_dir/README.md" 2>/dev/null || true chown "$ACTUAL_USER:$ACTUAL_USER" "$_dir/README.md" 2>/dev/null || true
} }
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -361,6 +366,7 @@ PHPINI
" "
fi fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << WPCOMPOSE cat > docker-compose.yml << WPCOMPOSE
name: $CONTAINER name: $CONTAINER
@@ -398,6 +404,7 @@ networks:
${_CADDY_NET_SECTION} ${_CADDY_NET_SECTION}
WPCOMPOSE WPCOMPOSE
backup_if_exists .env
cat > .env << WPENV cat > .env << WPENV
TZ=$TZ_VAL TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
+27 -1
View File
@@ -26,6 +26,31 @@ set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ── Self-install a PATH wrapper, so "cd into the repo every time" stops
# being necessary after the first run ────────────────────────────────────
# ${BASH_SOURCE[0]}-based HERE above means a plain symlink into
# /usr/local/bin wouldn't resolve correctly (bash doesn't follow symlinks
# for BASH_SOURCE, so a symlinked invocation would set HERE to the
# symlink's own directory, not this repo's) — a thin wrapper that execs
# THIS checkout's setup.sh by its real, already-resolved path sidesteps
# that entirely. Runs on every invocation (bare, --list/--status, or a
# service name) but is idempotent and silent unless something actually
# needs writing, so it doesn't add noise to a normal run. Root-only: a
# non-root invocation (e.g. --list) can't write to /usr/local/bin anyway,
# and skipping silently beats a permission-denied on every read-only
# command.
if [ "${EUID:-$(id -u)}" -eq 0 ]; then
_CLI_WRAPPER="/usr/local/bin/post-install"
_WANT_WRAPPER="#!/bin/bash
exec \"${HERE}/setup.sh\" \"\$@\""
if [ "$(cat "$_CLI_WRAPPER" 2>/dev/null)" != "$_WANT_WRAPPER" ]; then
if printf '%s\n' "$_WANT_WRAPPER" > "$_CLI_WRAPPER" 2>/dev/null && chmod +x "$_CLI_WRAPPER" 2>/dev/null; then
echo "[INFO] Installed 'post-install' — run it from anywhere from now on (e.g. post-install asterisk)."
fi
fi
unset _CLI_WRAPPER _WANT_WRAPPER
fi
# whiptail requires a valid TERM; when piped through bash (curl | bash) TERM # whiptail requires a valid TERM; when piped through bash (curl | bash) TERM
# may be unset, causing raw-mode to fail and arrow keys to leak to the shell. # may be unset, causing raw-mode to fail and arrow keys to leak to the shell.
export TERM="${TERM:-xterm-256color}" export TERM="${TERM:-xterm-256color}"
@@ -100,6 +125,7 @@ is_installed() {
kdeconnect) command -v kdeconnect >/dev/null 2>&1 ;; kdeconnect) command -v kdeconnect >/dev/null 2>&1 ;;
silent-send) [ -d "$ACTUAL_HOME/silent-send/.git" ] ;; silent-send) [ -d "$ACTUAL_HOME/silent-send/.git" ] ;;
sync-cc) [ -f "$ACTUAL_HOME/sync-cc/sync_cc.py" ] ;; sync-cc) [ -f "$ACTUAL_HOME/sync-cc/sync_cc.py" ] ;;
claude-cli) [ -f "$ACTUAL_HOME/.claude-shared/CLAUDE.md" ] ;;
sky-cam) [ -d "$ACTUAL_HOME/sky-cam/.git" ] ;; sky-cam) [ -d "$ACTUAL_HOME/sky-cam/.git" ] ;;
sky-cam-frigate) [ -d "$ACTUAL_HOME/sky-cam/.git" ] && [ -f "$ACTUAL_HOME/sky-cam/frigate-retime.sh" ] ;; sky-cam-frigate) [ -d "$ACTUAL_HOME/sky-cam/.git" ] && [ -f "$ACTUAL_HOME/sky-cam/frigate-retime.sh" ] ;;
# Either directory counts: boxes set up before the droplet edition was # Either directory counts: boxes set up before the droplet edition was
@@ -130,7 +156,7 @@ is_installed() {
# is_installed() as 0 or 1. # is_installed() as 0 or 1.
install_count() { install_count() {
case "$1" in case "$1" in
base|glow|crowdsec|security-dashboard|kdeconnect|silent-send|sync-cc|sky-cam|sky-cam-frigate|asterisk|pstn-trunk|sms-inbound|ssh-config|ssh-key-import) base|glow|crowdsec|security-dashboard|kdeconnect|silent-send|sync-cc|claude-cli|sky-cam|sky-cam-frigate|asterisk|pstn-trunk|sms-inbound|ssh-config|ssh-key-import)
is_installed "$1" && echo 1 || echo 0 ;; is_installed "$1" && echo 1 || echo 0 ;;
wordpress) wordpress)
find "$DOCKER_DIR" -mindepth 1 -maxdepth 1 -name 'wordpress-*' -type d 2>/dev/null | wc -l ;; find "$DOCKER_DIR" -mindepth 1 -maxdepth 1 -name 'wordpress-*' -type d 2>/dev/null | wc -l ;;
+7 -4
View File
@@ -56,9 +56,13 @@ fi
# ── Container + directory detection ───────────────────────────────────────── # ── Container + directory detection ─────────────────────────────────────────
section "Detecting install" section "Detecting install"
CONTAINER="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -m1 -E '^easy-asterisk(-do)?$' || true)" # "asterisk" is this repo's current container name; "easy-asterisk" is what
# an install kept from before that rename (never silently renamed under a
# running deployment); "easy-asterisk-do"/"asterisk-do" cover a DigitalOcean
# droplet install, old or new naming. Whichever is actually running wins.
CONTAINER="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -m1 -E '^(easy-)?asterisk(-do)?$' || true)"
if [ -z "$CONTAINER" ]; then if [ -z "$CONTAINER" ]; then
fail "No running easy-asterisk / easy-asterisk-do container found — is asterisk installed and started?" fail "No running asterisk / easy-asterisk / *-do container found — is asterisk installed and started?"
echo "" echo ""
echo " $PASS passed, $WARN warnings, $FAIL failed. Stopping — nothing else can be checked without a running container." echo " $PASS passed, $WARN warnings, $FAIL failed. Stopping — nothing else can be checked without a running container."
exit 1 exit 1
@@ -196,8 +200,7 @@ if [ -z "$TURN_SERVER" ]; then
warn "Add it via: sudo ./setup.sh asterisk (update mode)" warn "Add it via: sudo ./setup.sh asterisk (update mode)"
else else
if grep -q '^ coturn:' "$EA_DIR/docker-compose.yml" 2>/dev/null; then if grep -q '^ coturn:' "$EA_DIR/docker-compose.yml" 2>/dev/null; then
COTURN_CONTAINER="easy-asterisk-coturn" COTURN_CONTAINER="${CONTAINER}-coturn"
[[ "$CONTAINER" == *-do ]] && COTURN_CONTAINER="easy-asterisk-do-coturn"
ok "Using an embedded, per-Asterisk coturn ($COTURN_CONTAINER); tested separately below." ok "Using an embedded, per-Asterisk coturn ($COTURN_CONTAINER); tested separately below."
else else
COTURN_CONTAINER="coturn" COTURN_CONTAINER="coturn"
+27 -2
View File
@@ -596,7 +596,9 @@ services:
capabilities: [gpu] capabilities: [gpu]
healthcheck: healthcheck:
test: ["CMD","ollama","list"] test: ["CMD","ollama","list"]
interval: 30s; timeout: 10s; retries: 5 interval: 30s
timeout: 10s
retries: 5
open-webui: open-webui:
image: ghcr.io/open-webui/open-webui:main image: ghcr.io/open-webui/open-webui:main
@@ -630,7 +632,9 @@ services:
- ANONYMIZED_TELEMETRY=FALSE - ANONYMIZED_TELEMETRY=FALSE
healthcheck: healthcheck:
test: ["CMD-SHELL","wget -qO- http://localhost:8000/api/v2/heartbeat || exit 1"] test: ["CMD-SHELL","wget -qO- http://localhost:8000/api/v2/heartbeat || exit 1"]
interval: 15s; timeout: 5s; retries: 5 interval: 15s
timeout: 5s
retries: 5
rag-server: rag-server:
image: python:3.11-slim image: python:3.11-slim
@@ -860,6 +864,27 @@ echo "Reasoning model (DeepSeek-R1 14B — optional)..."
read -rp "Pull DeepSeek-R1:14b for planning/reasoning? [y/N]: " DR read -rp "Pull DeepSeek-R1:14b for planning/reasoning? [y/N]: " DR
[[ "\${DR,,}" == "y" ]] && docker exec ollama ollama pull deepseek-r1:14b [[ "\${DR,,}" == "y" ]] && docker exec ollama ollama pull deepseek-r1:14b
echo ""
echo "Vision model (optional — image understanding: Mealie's \"import recipe from"
echo "photo\", attaching images in Open WebUI chat, etc.). None of the models"
echo "above can read an image; pick one of these if you need that:"
echo " 1) moondream ~1.7 GB Moondream AI — tiny, built for CPU-only/"
echo " weak or old GPU hardware. Recommended"
echo " default if you have no GPU or a low-VRAM one."
echo " 2) llava:7b ~4.7 GB General-purpose vision, moderate resources."
echo " 3) qwen2.5vl:7b ~6 GB Stronger accuracy, needs more RAM/VRAM."
echo " 4) llama3.2-vision:11b ~7.9 GB Meta's vision model — heaviest of these four."
read -rp "Pull a vision model? [1-4, blank to skip]: " VM
case "\$VM" in
1) docker exec ollama ollama pull moondream ;;
2) docker exec ollama ollama pull llava:7b ;;
3) docker exec ollama ollama pull qwen2.5vl:7b ;;
4) docker exec ollama ollama pull llama3.2-vision:11b ;;
"") : ;;
*) echo "Unrecognized choice '\$VM' — skipping. Pull manually later with:"
echo " docker exec ollama ollama pull <model>" ;;
esac
echo "" && docker exec ollama ollama list echo "" && docker exec ollama ollama list
PULLSH PULLSH
chmod +x "$BASE/pull-models.sh" chmod +x "$BASE/pull-models.sh"