222 Commits
Author SHA1 Message Date
Outis 5c13054cdd Merge pull request #444 from outis1one/claude/gitea-webhook-base-url-9ofg1n
Fix Authelia remember_me not actually keeping sessions alive
2026-09-09 09:16:51 -04:00
Outis 7d5674aad8 Merge pull request #443 from outis1one/claude/wolf-controller-setup-vl05t5
wolf: fix stale command list in the post-install summary, surface the…
2026-09-09 09:16:19 -04:00
Claude 2d82b2b278 Fix Authelia remember_me not actually keeping sessions alive
inactivity (idle timeout) was independent of remember_me and stayed at a
much shorter default (2h), so a long remember_me got silently overridden
by ordinary daily gaps between visits. install_authelia()'s template now
defaults inactivity to match remember_me, and the "Change remember me
duration" menu option now writes both keys together instead of just one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148pWopbt3tEKZWHYuHTb3c
2026-09-08 22:43:20 +00:00
Claude 08a2617b06 wolf: fix stale command list in the post-install summary, surface the Steam Input workflow
The final echo summary still advertised a removed `apps` command and left out
everything added since (cores, backup, controllers, steam-add-nonsteam-game,
steam-setup-frontends, cemu-clone-controller, cemu-sync-controllers,
install-completion, etc.). Also add a short pointer to the Steam-as-4-controller-hub
workflow (documented in depth further down in README.md) right in the install
summary, since it's currently only discoverable by reading the generated README.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V99t5754SyXdnMTpVe2ba5
2026-09-07 22:29:55 +00:00
Outis 39387b5e0f Merge pull request #442 from outis1one/claude/wolf-cemu-four-controllers-373xn8
Claude/wolf cemu four controllers 373xn8
2026-09-05 15:00:15 -04:00
Claude e67ac50c61 wolf: document the ES-DE/RetroArch-in-Steam workflow in the generated README
The steam-setup-frontends command and the ES-DE/RetroArch AppImage
download step had no matching section in the ~/docker/wolf/README.md
content this file generates, unlike every other manage.sh command. Adds
one, alongside the existing Cemu-in-Steam section: why you'd want it,
how the mounts/cores are already shared, and the two honest caveats
(returning to Steam from ES-DE only works via this path, and whether
controller mappings sync across separately-paired Wolf clients is
expected but unconfirmed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z4nqULUEipWNgBsPoSuAb
2026-09-05 18:58:04 +00:00
Claude 3ebbeba672 wolf: add steam-setup-frontends to wait for Steam sign-in then auto-wire ES-DE/RetroArch
Steam Guard's QR-code sign-in can't be scripted (needs a phone approving
a prompt), so this polls for it instead: starts Wolf if needed, waits
for Steam's userdata/ to appear (or proceeds immediately if already
signed in), then re-invokes the existing steam-add-nonsteam-game command
for whichever of ES-DE.AppImage/RetroArch.AppImage was downloaded during
install. Points to ./manage.sh cores all for the shared cores/shaders/
overlays directory rather than duplicating that download logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z4nqULUEipWNgBsPoSuAb
2026-09-04 21:30:24 +00:00
Claude 07394769ca wolf: add optional ES-DE/RetroArch AppImage downloads for Steam Input
Both projects ship official standalone Linux AppImages separate from
the esde/retroarch Wolf catalog containers this repo already runs.
Adding either one to Steam as a non-Steam game (steam-add-nonsteam-game,
now reaching the same roms/saves/bios/retro-home/retroarch mounts as
the esde/retroarch containers) lets Steam Input assign a 4th controller
its own identity by device path, past Wolf's 3-concrete-pad-type ceiling.

ES-DE is hosted on GitLab rather than GitHub, so this adds a GitLab
Releases API counterpart to the existing GitHub-based download helper.
RetroArch's own buildbot doesn't publish through either API, so this
uses hizzlekizzle/RetroArch-AppImage, the community nightly-build
project the AppImage catalogs themselves point to (flagged as
third-party, same treatment this file already gives the Dolphin
community build). Both downloads are opt-in (default no) and symlink
to a fixed filename so steam-add-nonsteam-game's case-sensitive
substring match finds them regardless of the vendor's own asset name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z4nqULUEipWNgBsPoSuAb
2026-09-04 21:04:00 +00:00
Claude ae939c4085 wolf: mount ROMs/saves/BIOS/retro-home/retroarch into the Steam container
Lets a manually-downloaded ES-DE or RetroArch AppImage, added via
./manage.sh steam-add-nonsteam-game, see the same library, cores, and
ES-DE settings/custom systems (TI-99, Wii U) the esde/retroarch
containers already have, instead of starting from an empty config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z4nqULUEipWNgBsPoSuAb
2026-09-04 14:15:33 +00:00
Outis 28996eff57 Merge pull request #441 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
wolf: fix cemu-sync-controllers silently finding zero joysticks
2026-09-03 22:48:40 -04:00
Claude 93efe0d607 wolf: fix cemu-sync-controllers silently finding zero joysticks
docker exec needs an explicit -i flag to forward stdin into the
container process; without it, the heredoc piped into `python3 -`
never reached the containerized script, which ran empty and printed
nothing. cemu-sync-controllers then misread that empty output as
"SDL reported zero joysticks" — a false negative with a working set of
4 controllers already confirmed live in ES-DE, not an actual SDL or
GUID problem. Root-caused against the user's own live output: the
exact same probe script, run directly (not via the manage.sh command),
found real device nodes (js0-js4) present in the same container at the
same time cemu-sync-controllers reported zero.

Audited every other docker exec call in this file for the same
stdin-via-heredoc pattern; this was the only one missing -i.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-04 02:48:08 +00:00
Outis 79861c72f3 Merge pull request #440 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
wolf: add ./manage.sh cemu-sync-controllers — full auto-clone via SDL
2026-09-03 22:40:33 -04:00
Claude 2422ce1385 wolf: add ./manage.sh cemu-sync-controllers — full auto-clone via SDL
cemu-clone-controller still needed one manual Cemu GUI step per new
device (click +, select it, map one button, Save) just to learn its
real uuid. This eliminates that too: instead of getting the uuid from
Cemu, it asks SDL directly (SDL_JoystickGetDeviceGUID/GetGUIDString via
ctypes against libSDL2 — the exact library Cemu itself links against)
for the live GUID of every controller connected to the active session,
then clones the proven-working mapping onto each one automatically.

Confirmed live end-to-end on the user's real box: querying SDL this
way inside a running ES-DE session reproduced the exact uuids Cemu had
already written by hand for two different controllers (Nintendo Switch
Pro and Xbox One S) — byte-for-byte identical to their real
controllerProfiles/controllerN.xml. This is what makes trusting SDL as
the uuid source safe, after an earlier attempt to reverse-engineer the
GUID's CRC16 portion by hand (tried 7 different CRC16 variants) failed
to match either device.

Also improved cemu-clone-controller's sibling: the synced profile's
display_name now comes from SDL's own live device name (when the probe
reports one) rather than always inheriting the mapping template's name,
which was a real but purely cosmetic issue caught while re-testing.

Verified end-to-end in an isolated harness against the real captured
controller0.xml/controller1.xml: idempotent skip on already-synced
slots (no needless backups, existing files left byte-identical), a new
slot correctly cloned with the live name and same 24-pair mapping set,
and valid XML output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-04 02:25:22 +00:00
Outis 6fc6c3b84d Merge pull request #439 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-03 20:52:47 -04:00
Claude e6522eadec wolf: add ./manage.sh cemu-clone-controller
Cemu's own Wolf-facing controller slots (up to 4) still each need a
separate manual bind through Cemu's Input Settings even after
./manage.sh controllers makes them distinct SDL devices — that dialog's
Save button is documented elsewhere in this README as getting cut off
the viewport, making binding all 4 slots by hand painful.

Validated the underlying assumption against two real, separately
hand-mapped profiles pulled live from a working install (Nintendo
Switch Pro on controller0.xml, Xbox One S on controller1.xml): both
contain the exact same 24 <mapping>/<button> pairs, just in a different
order — real proof Cemu's mapping format is device-agnostic and
order-independent, not just the README's prior unverified claim.

New command clones a proven-working <mappings> block onto a new
device's controllerN.xml, needing only that device's own real <uuid>
(which still has to come from Cemu itself — a hand-computed SDL GUID
risks not matching what SDL actually reports for the live device, so
this never guesses one). Getting that uuid only needs a single-button
minimal bind in Cemu's UI, not full mapping, since only the uuid gets
kept from it.

Verified end-to-end against the real captured controller0.xml/
controller1.xml content: cloning controller0's mappings onto
controller1's real uuid reproduces the exact same 24-pair set Cemu
itself wrote, and the output validates as well-formed XML.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-04 00:45:22 +00:00
Claude 2dcfaafc87 wolf: add Cemu TV-audio-stuck diagnostic block to the generated README
Copyable follow-up for the existing Cubeb/PulseAudio hang note: checks
whether a full Wolf restart has actually happened, whether PulseAudio
inside the Desktop container has any sinks at all, and pulls Cemu's own
log.txt plus its current <Audio> settings.xml block — narrows "stuck on
Disabled" down to a container-level audio problem vs. Cemu's own
device-switch path before guessing at a fix.

Verified the heredoc escaping by rendering the write_readme block through
a real bash heredoc and confirming $WOLF_DIR interpolates while every
other $ stays literal in the output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 19:23:20 +00:00
Outis 33e4f64d69 Merge pull request #438 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-03 14:58:11 -04:00
Claude cbfc28c2dd wolf: fix ES-DE ROMDirectory staying blank despite the self-healing patch
Root-caused against the user's own live output: the settings file had
<string name="ROMDirectory" value="" /> — the key was present, just
empty. The self-healing check only tested whether the string
"ROMDirectory" appeared anywhere in the file, so a key that exists with
a blank value (ES-DE can write this itself if its own first-run "select
ROM directory" step goes unanswered in a headless Moonlight session,
saving an empty path back over GOW's template) looked "already
populated" and got skipped — leaving ROM discovery broken even after
the fix had run.

Now ROMDirectory is independently forced to /ROMs whenever its value is
blank, on top of (not instead of) the existing RunInBackground patch —
no longer gated on the key's mere presence. Verified against the exact
reported bug (ROMDirectory present but blank) plus the existing missing-
file, already-correct, and idempotent-rerun scenarios in isolated /tmp
harnesses before touching the real script.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 18:57:29 +00:00
Claude a212be09c3 wolf: add ./manage.sh steam-add-nonsteam-game, mount emulators/ into Steam
Adding an emulator (Cemu, etc.) to Steam as a non-Steam game previously
needed Steam's own Big Picture file-browser flow through Moonlight. This
writes the shortcut directly into Steam's binary shortcuts.vdf instead,
matching this repo's existing no-manual-wizard pattern (ge-proton,
install-ea-app). Motivated by wanting to test whether Steam Input's
per-device controller tracking (distinct device paths, not SDL GUIDs)
can hand Cemu 4 explicitly-assigned controllers where ES-DE/Cemu's own
SDL-based handling can't tell identical controllers apart.

- New generic binary VDF (KeyValues) parser/serializer: round-trips any
  existing shortcuts.vdf entries byte-for-byte and only inserts/replaces
  the one entry matching the given Exe path, so it's safe against a file
  that already has real, hand-configured shortcuts. Verified in isolated
  /tmp harnesses: fresh file, idempotent re-add, a second distinct entry,
  and preserving a synthetic pre-existing GUI-set entry (icon,
  LaunchOptions, tags) untouched.
- Steam's own CATALOG entry had no mount for emulators/ at all (unlike
  esde/retroarch) — added emulators:/home/retro/Applications so an
  AppImage is actually reachable from inside the Steam container. Like
  the ES-DE settings mount, this only takes effect on a freshly created
  WolfSteam container (Wolf reuses existing ones) — noted in the README.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 18:52:31 +00:00
Outis 8a9241f0ff Merge pull request #437 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
wolf: make ES-DE RunInBackground fix fully self-healing, no manual step
2026-09-03 14:40:09 -04:00
Claude 5893346625 wolf: make ES-DE RunInBackground fix fully self-healing, no manual step
Previous version only patched an already-populated es_settings.xml,
requiring a manual "connect via Moonlight once" step before the fix
could apply — the user correctly pointed out the script can't know
whether that step has been done, so a manual dependency isn't a real
fix. This embeds GOW's own real es_settings.xml template (fetched from
games-on-whales/gow, RunInBackground pre-flipped false) and writes it
directly whenever the on-disk file is missing or is the old broken
stub (detected by the absence of ROMDirectory, which only a real
GOW/ES-DE-written file has). An already-populated file still only gets
the one RunInBackground line patched, leaving everything else alone.

Verified against all 4 scenarios in isolated /tmp harnesses before
touching the file: missing file, the exact broken stub from the user's
regression, GOW's real full template, and idempotent re-runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 18:35:44 +00:00
Outis 5847b81dfd Merge pull request #436 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-03 14:21:58 -04:00
Claude 52207598b9 Fix real regression: ES-DE Run-in-background patch broke ROM discovery
Confirmed live on a real install: after the previous commit, ES-DE found
zero games on every system. Root-caused by walking GOW's own
apps/es-de/build/scripts/startup.sh line by line: GOW's image bakes a
FULL es_settings.xml template (100+ settings, including
ROMDirectory=/ROMs, confirmed against the real template file in the
games-on-whales/gow repo) and its own startup script copies that
template into place exactly once, the very first time the container
starts, gated on the settings file not already existing.

The previous fix pre-created a minimal stub file (containing only
RunInBackground) before the container had ever started - GOW's startup
script saw that file "already there", skipped writing its own template
entirely, and ES-DE fell back to vanilla upstream defaults for
everything else in that file, including ROMDirectory, which is not
/ROMs by default. Every system's games vanished as a result.

Also corrects an earlier wrong claim in both the code comment and
README: ES-DE's own *compiled* default for RunInBackground is false, but
GOW's own template deliberately sets it to true - that's the real,
confirmed source of the original background-launch bug, not an
accidental toggle as previously assumed.

Fix: only ever patch an es_settings.xml that already exists with real
content (`[ -s "$_ESDE_SETTINGS" ]`) - never create one. On a genuinely
fresh install, before EmulationStation has ever launched once, the
installer now says so explicitly and defers rather than guessing, with
clear instructions to connect once and re-run. Verified against GOW's
actual real template: the patch flips just RunInBackground (line count
unchanged, ROMDirectory and everything else untouched), is idempotent on
re-run, and the skip-when-absent gate was verified directly against both
the missing-file and populated-file cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 18:15:50 +00:00
Claude c57f760fdc manage.sh controllers: collapse duplicate-paired devices to one entry
Live user feedback: being asked to pick between 6 numbered entries that
were all the exact same device (client_id repeated 6x from re-pairing)
was genuinely confusing, especially right when the user was already
trying to get to the controller-type poll further down the flow.

The picker now dedupes to distinct client_ids only, first-occurrence
order, tagging a collapsed entry "(paired Nx)" - matches what Wolf's own
get_client_by_id() actually resolves to anyway (first match for a given
id), so nothing is lost by not offering the later duplicates as separate
choices. Verified against the user's real 8-entry (6 duplicate + 2
unique) client list, both with and without an active session, confirming
the controller log-poll still runs correctly right after selection in
both cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 18:03:45 +00:00
Outis 57fd74f5af Merge pull request #435 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-03 13:57:42 -04:00
Claude 4ed7a9d2d5 Force ES-DE's "Run in background" off durably; mount ~/ES-DE/settings
Fixes a real, confirmed-live, cross-system bug: with ES-DE's "Run in
background (while game is launched)" enabled, ES-DE keeps listening to
every controller even after a game/emulator takes focus - a second
controller's input can reach ES-DE's own menu and launch a completely
different game underneath whatever's already running. ES-DE's own
USERGUIDE.md names this exact failure mode.

ES-DE's compiled default for this setting is already off (confirmed
against its real Settings.cpp), but nothing in this repo asserted that
durably, and ~/ES-DE (settings, gamelists, scraped media, logs) had no
mount onto the game drive at all - confirmed against ES-DE's own source
(getAppDataDirectory() is a plain $HOME/ES-DE) and GOW's own es-de
startup script. Wolf normally reuses the same app container across
sessions rather than recreating it each time (confirmed against Wolf's
docker.cpp - it only removes the container on exit if
WOLF_STOP_CONTAINER_ON_EXIT=TRUE, which this repo never sets), so this
mostly didn't bite day to day, but it meant the setting - and everything
else under ~/ES-DE - wasn't safe across an actual reinstall the way
roms/saves/BIOS already are.

Mounts esde-settings/ onto ~/ES-DE/settings specifically (additive,
doesn't touch the existing custom_systems mount) and writes a surgical,
idempotent RunInBackground=false into es_settings.xml on every install -
never a full rewrite, since that file is the user's own complete
settings state. Verified the write logic against fresh-file, existing-
file-with-other-settings, and flip-an-existing-true-to-false cases, plus
idempotent re-runs.

Gamelists/scraped-media durability under ~/ES-DE is a related, noted gap
- not fixed here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 17:54:47 +00:00
Claude 9ba1d7e9db manage.sh controllers: poll Wolf's logs, label slots ordinally; document Cemu profile-editing recovery
Two follow-ups from live testing that just confirmed the whole
controllers_override chain works end to end:

1. manage.sh controllers now polls `docker compose logs wolf` for
   "Creating <TYPE> joypad for controller <N>" lines (most recent per
   slot wins) and shows what it last saw before asking anything, using
   it as the suggested default at each prompt. Every prompt is now
   labeled "1st controller"/"2nd controller"/etc - always meaning
   Wolf's own 0-indexed "controller 0"/"controller 1" - instead of
   asking for a raw slot number, closing the exact client-vs-controller
   0-vs-1 confusion surfaced live earlier. Documents plainly that this
   can show what TYPE a slot last used but can't show or control WHICH
   physical controller becomes which slot - that's decided by Moonlight
   client-side, upstream of Wolf's API entirely.

2. Documents a full recovery procedure in the Wii U section for when
   Cemu's Input Settings Save button is cut off-screen (confirmed live:
   happens even maximized, at higher resolutions, and closing the
   window doesn't save) - an Alt-drag/Alt+F7 window-manager fix first,
   then editing Cemu's own controllerProfiles/controllerN.xml directly
   on the host (plain XML, not inside the container) as a full GUI
   bypass. Documents the key insight that made this actually work live:
   <mapping>/<button> pairs are universal SDL_CONTROLLER_BUTTON_*
   semantics (confirmed earlier against real captured profiles from
   different controller brands), so a working mapping can be copied
   verbatim into a different device's empty <mappings/> block without
   redoing the live button-capture in Cemu's GUI at all.

Verified the redesigned manage.sh controllers flow end to end against a
mocked docker compose logs matching the user's real log output (both
the log-seen and no-logs-seen paths), plus the ordinal-suffix logic in
isolation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 17:44:53 +00:00
Claude 4b5ca9f6ea Disambiguate manage.sh controllers wording: client vs controller
Live user feedback: "Exactly one client is actively streaming right
now" sitting directly above "How many controller slots" read as "only
one controller is connected" - an easy misread since "client" (a paired
Moonlight session/device) and "controller" (a physical gamepad slot
within that session) are conflated by ordinary English but mean
completely different things here. The tool has no way to detect
controller count at all; it only ever picks which Moonlight
session/device to configure.

Reworded every message in this flow to say "Moonlight session/device"
instead of bare "client", and added an explicit line before the
controller-count prompt clarifying it's a separate question the user
has to answer themselves. Verified the full flow still runs correctly
end to end against the same mocked duplicate-heavy client data used
earlier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 16:23:02 +00:00
Outis 2517b31336 Merge pull request #434 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
manage.sh controllers: identify clients by live session IP, not opaqu…
2026-09-03 12:13:27 -04:00
Claude b671c1b2ec manage.sh controllers: identify clients by live session IP, not opaque ID
Confirmed live: the paired-clients list is a wall of meaningless
cert-derived numbers with nothing human-readable attached (verified
against Wolf's own PairedClient API schema - no name, no IP field
exists there at all), so there was no way to tell which entry was the
user's actual device. Made worse by Wolf not deduping repeated pairings
of the same device (the user's real box showed the same client_id 6
times).

Fix: cross-reference against GET /api/v1/sessions, which DOES carry
client_ip for every currently-streaming session, and tag each paired
entry as "ACTIVE - streaming from <ip>" when it matches one. When
exactly one client is actively streaming, it's now auto-selected with
no prompt - the practical case for a single-user setup. Falls back to
the manual picker when nothing's active or multiple clients are (now
showing which IP is which, so the choice is no longer a guess). Verified
against the user's real duplicate-heavy client list plus mocked active-
session data covering: single active session (auto-select), no active
sessions (manual fallback), and two distinct active sessions (manual
fallback with both IPs shown).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 16:03:43 +00:00
Outis d0c444e63f Merge pull request #433 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Fix manage.sh controllers: sudo needed for Wolf's root-owned API socket
2026-09-03 11:56:11 -04:00
Claude 0f89a3d534 Fix manage.sh controllers: sudo needed for Wolf's root-owned API socket
Confirmed live: a plain-user curl against /var/run/wolf/wolf.sock failed
with "Immediate connect fail ... Permission denied". The socket is
srwxr-xr-x root:root - Docker auto-creates the host-side /var/run/wolf
bind-mount source as root, and Wolf itself runs as root in its
container, so group/other never had write permission on the socket file
(required to connect() to a unix socket, not just read/traverse it -
`ls -la` on the containing dir working fine was a red herring).

Fix: sudo on both curl calls, matching how every other root-owned Wolf
state file ($WOLF_CFG) is already touched elsewhere in this script via
`sudo python3 -`. Documents the sudo prompt in the README too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 15:55:28 +00:00
Outis d84b958937 Merge pull request #432 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-03 11:51:29 -04:00
Claude 866d895357 Document exact controller types and the 4-controller case in README
Spells out what the three controllers_override values actually create
(Xbox One, PS5 DualSense, Switch Pro Controller - confirmed against
inputtino's own source, not just the enum names) instead of leaving the
reader to guess from bare AUTO/XBOX/PS/NINTENDO tokens, plus a note that
forcing a non-matching type only affects on-screen button-prompt
artwork, not functionality.

Adds a 4-controller section: Cemu's own Input Settings goes up to 8
slots and Wolf's wire protocol has no hardcoded 4-controller cap
(confirmed against control/input_handler.cpp), so the ceiling in
practice is the Moonlight client's own limit. With only 3 concrete
forced types available, a 4th controller can't get a guaranteed-unique
GUID - documents the one-at-a-time Cemu binding workaround for that
case, flagged as reasoned but not confirmed live.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 15:46:10 +00:00
Claude 2005534b12 Add ./manage.sh controllers to fix multi-controller disambiguation
Root cause (confirmed against inputtino's own uinput device-creation
source, and against Wolf's real virtual-pad architecture): every
virtual gamepad Wolf creates of the same type gets an identical SDL
GUID, since a GUID identifies a controller model, not a physical
instance - the same behavior two real identical controllers would have.
This isn't a Cemu bug specifically; it's why any app whose own
controller picker keys off GUID (Cemu confirmed live) can't reliably
tell two same-type Wolf pads apart, driving every player from
controller 1.

Wolf's own per-client `controllers_override` setting sidesteps this at
the root by forcing each controller slot to a genuinely different pad
type (e.g. slot 1 = Xbox, slot 2 = PlayStation), so their vendor/product
IDs - and SDL GUIDs - actually differ. `./manage.sh controllers` drives
this via Wolf's real REST API (confirmed against its OpenAPI schema,
not its docs page's own stale example command) rather than hand-editing
config.toml: lists paired clients, prompts for a pad type per slot, and
POSTs the update. Needs Wolf's API socket exposed to the host
(WOLF_SOCKET_PATH + a /var/run/wolf bind mount, matching Wolf's own
documented pattern), added to the wolf service's docker-compose.yml.

Full flow verified with a standalone test harness (mocked curl against
real API response shapes, a real AF_UNIX socket file) covering: single-
vs multi-client selection, invalid selection, invalid slot count,
lowercase/unrecognized type input, and the missing-socket path.

Also documents live user feedback that AntiMicroX (added last session)
is confirmed not working for TI-99/4A - flagged in the README rather
than left silently unverified, with this fix noted as the actively
maintained path for the multi-controller problem instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 15:30:26 +00:00
Outis a3642c5159 Merge pull request #431 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Backfill PCEE2 as a PS2 alternative emulator in ES-DE
2026-09-03 11:18:40 -04:00
Claude 8aaaf993ac Backfill PCEE2 as a PS2 alternative emulator in ES-DE
The PCEE2 libretro core (a separate, actively-developed PCSX2 port,
WizzardSK/pcee2-libretro) is only in ES-DE's own bundled es_systems.xml
as of 3.5.0, which hasn't been released yet - confirmed against ES-DE's
own CHANGELOG.md and by diffing the v3.4.1 tag's real ps2 system block
against git master's (identical except for this one missing <command>
line). ghcr.io/games-on-whales/es-de:edge always installs the latest
*released* AppImage (confirmed against gow's own Dockerfile, which
queries GitLab's releases API), so the container this repo runs
genuinely has no PCEE2 entry at all yet - not a missing core download,
a missing menu entry.

Backfills it via the same esde-custom-systems/es_systems.xml mechanism
already used for the TI-99/4A and Wii U customizations, replicating the
real v3.4.1 ps2 block verbatim plus the one new command (as the default,
matching upstream's own placement). The core file itself needs no extra
handling - confirmed live against the libretro buildbot that
pcee2_libretro.so.zip is already covered by the normal RetroArch cores
pre-download.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 15:15:25 +00:00
Outis 09a24c2f16 Merge pull request #430 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-03 11:08:43 -04:00
Claude 0be27b15e9 Add opt-in AntiMicroX gamepad remapping, scoped to TI-99/4A and Wii U
AntiMicroX maps gamepad buttons to synthetic keyboard/mouse events -
useful here because ti99sim-sdl's own joystick handling only ever emits
digit keys 1-9 for a raw button (confirmed against its source), with no
path to 0/Enter/Q/Esc, and because it's a plausible angle on Cemu not
reliably telling apart two Wolf virtual pads that share an SDL GUID.

Scoped to just these two ES-DE systems via a second, separately-labeled
"(AntiMicroX)" <command> alongside each one's existing default - ES-DE's
own multi-command "alternative emulators" mechanism - rather than
touching every system. Each launches through a small wrapper script
that starts AntiMicroX hidden (--no-tray --hidden --eventgen uinput,
flags confirmed against AntiMicroX's own commandlineutility.cpp source)
against a profile the user builds themselves via AntiMicroX's own GUI in
the Desktop/XFCE app, execs the real emulator, and kills AntiMicroX on
exit.

AntiMicroX needs /dev/uinput to inject events under ES-DE's Sway/Wayland
session (its XTest backend needs Xwayland, not present here), so the
esde catalog entry now requests it via both GOW_REQUIRED_DEVICES and a
real device grant - and make_app_block/the reinstall-update path both
learn to read/refresh a per-app 'devices' field instead of always
emitting an empty array, so this actually reaches an existing install on
a rerun, not just a fresh one (verified live against a synthetic
pre-uinput-era config.toml).

Also fixes a latent whitespace-eating bug in every custom_systems
strip-and-reappend regex in this file (trailing \s*\n? reached into the
next sibling block's leading indentation once two custom systems
coexist in the same file) - cosmetic only, but it would have made every
rerun churn indentation between the ti994a and wiiu blocks. Caught and
verified via a standalone test harness exercising all four
strip-and-reappend blocks together across repeated passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 15:02:06 +00:00
Claude 9714bfca2e Document TI-99/4A controller limits and keyboard shortcuts in README
Joystick buttons beyond the first only reach digit keys 1-9 in
ti99sim-sdl's own source (src/sdl/ti994a-sdl.cpp) - there's no path to
0, Enter, Q, or Esc from a gamepad. Documents the real Esc-to-exit
behavior and full FCTN key row (Alt+1 through Alt+9, Alt+=), both
confirmed against the emulator's own bundled doc/README.html.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 14:39:48 +00:00
Outis 93288be7e2 Merge pull request #429 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-03 10:32:06 -04:00
Claude e3874b2ebe Launch TI-99/4A games fullscreen
ti99sim-sdl supports --fullscreen directly (confirmed via its own
--help output) — add it to the launch command so TI-99/4A games open
fullscreen the same way every other standalone emulator here does,
instead of a small windowed default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 14:30:39 +00:00
Claude 343c2ef68b Fix wrong return-to-launcher hotkey; document Cemu controller/audio setup
The README's "Return to launcher: Ctrl+Alt+Shift+W or START+UP+RB" was
wrong on both counts — confirmed live the controller combo did nothing.
The real, standard Moonlight client shortcuts (confirmed against
Moonlight's own documented shortcuts, not Wolf-specific) are
Ctrl+Alt+Shift+Q on a keyboard and Back/Select+Start+LB+RB pressed
together on a controller. Fixed in all three places this text appeared.

Also documented the actual working procedure for configuring a Cemu
controller and TV audio device, worked out live this session: both need
to be done from the Desktop (XFCE) app rather than ES-DE, since Cemu's
own Settings dialogs don't render correctly in ES-DE's Sway kiosk
session but do in a real XFCE window manager. Desktop shares the same
~/.config/Cemu as ES-DE, so the config carries over. Also notes the
real fix for Cemu's TV-audio-device hang (a full Wolf restart, not just
reconnecting) that worked in testing, and that /ROMs is now mounted in
Desktop too (previous commit) so Cemu can load games directly from
there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 06:15:59 +00:00
Outis 164d5e8891 Merge pull request #428 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Mount roms/saves into the Desktop app too
2026-09-03 01:50:20 -04:00
Claude 24fe5a3177 Mount roms/saves into the Desktop app too
The 'desktop' catalog entry only ever mounted emulators/ and the
persistent home — /ROMs didn't exist in that container at all.
Confirmed live: with the controller fix requiring a real XFCE session
to reach Cemu's own working dialogs, there was no way to actually load
a ROM once there (Cemu's own File/Load menu had nothing to browse to).
Add the same /ROMs and saves mounts es-de/retroarch already use, so a
standalone emulator run from Desktop can load games directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 05:43:32 +00:00
Outis 70f3bfbd85 Merge pull request #427 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-02 22:09:24 -04:00
Claude 2c93a2c7fd Fix unbound APP_KEYS variable in install_wolf()'s app injector
install_wolf()'s own copy of the app-injection step used $APP_KEYS instead
of its actual variable $_APP_KEYS (the underscore-prefixed name is
install_wolf()'s own; APP_KEYS is manage.sh's separate copy's variable).
Confirmed live: under 'set -u' without 'set -e' this was a non-fatal
"APP_KEYS: unbound variable" error printed mid-install right after "Wolf
restarted with updated config" — the script kept going, but the
Steam-storage-location success message never printed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 02:06:28 +00:00
Claude 5c3a38b9f0 Set APPIMAGE_EXTRACT_AND_RUN=1 on the Desktop app so AppImages can run
ghcr.io/games-on-whales/xfce has no libfuse2/libfuse3 at all (checked
against its own Dockerfile) and, unlike es-de's Dockerfile (which sets
this exact env var), no fallback either. Confirmed live: launching Cemu's
AppImage from a terminal inside the Desktop session failed outright with
the standard "AppImages require FUSE to run" error, even though the same
AppImage runs fine when ES-DE launches it — es-de's container already
carries this env var, xfce's doesn't. Adding it makes every AppImage
self-extract into a temp dir and run from there instead of trying to
FUSE-mount itself, matching how ES-DE already handles them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 21:53:38 +00:00
Outis 24b62b7415 Merge pull request #426 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-02 16:54:24 -04:00
Claude 52604b3ba6 Make app re-runs actually refresh a stale image/icon field
update_field() only ever got called for 'mounts' and 'env' when an app was
already present in config.toml, so the games-on-whales/desktop -> xfce
image-name fix from the previous commit would NOT have reached anyone who
already has a (broken) Desktop entry — re-running the installer or
./manage.sh apps would keep refreshing mounts/env but silently leave the
old, 404ing image reference in place forever.

update_field's own array-reformatting logic (scan forward for a closing
']') isn't safe to reuse for a single-line scalar field like image or
icon_png_path — there's no ']' on that line, so the scan would run into
an unrelated array further down the same block (e.g. 'ports = []') and
corrupt it. Added a separate update_scalar_field() that only ever
replaces the exact matched line, and wired it in for both 'image' and
'icon_png_path' in both copies of this app-injector script (install-time
and manage.sh's own 'apps' command) — verified locally against a
synthetic config.toml block that it replaces only the targeted app's own
fields and leaves a neighboring app's identically-named fields untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 20:51:59 +00:00
Claude 3d9df0793a Fix Wolf Desktop app's image reference: xfce, not desktop
ghcr.io/games-on-whales/desktop never existed. Confirmed live: Wolf logged
"[DOCKER] error 404 - No such image: ghcr.io/games-on-whales/desktop:edge"
and silently returned to the Moonlight app list with no other visible
error, making the Desktop tile look like it just didn't launch. The
games-on-whales/gow repo's apps/ directory names this app "xfce", and
ghcr.io/games-on-whales/xfce:edge is the real, currently published image
(confirmed against GHCR's own tag list for that package) — the icon path
uses the same "xfce" naming.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 20:49:32 +00:00
Outis 14541062fc Merge pull request #425 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Share ES-DE's emulator/config mounts with the Wolf Desktop app
2026-09-02 16:35:38 -04:00
Claude d954c605b0 Share ES-DE's emulator/config mounts with the Wolf Desktop app
The 'desktop' Wolf app (ghcr.io/games-on-whales/desktop:edge, full XFCE
session) had mounts=[] — no access to emulators/ or the persistent
.config/.local/share home ES-DE/RetroArch use. That meant there was no way
to run a standalone emulator like Cemu outside ES-DE's single-app Sway
kiosk session, which matters because a second top-level window from an
app (e.g. Cemu's own Settings/Input dialogs) can fail to ever get mapped
or focused there — confirmed live: clicking Cemu's General Settings
highlighted the menu entry but no dialog ever appeared, with no way to
recover control short of killing ES-DE.

Give Desktop the same emulators -> ~/Applications and retro-home(-data)
-> .config/.local/share mounts esde/retroarch already use, so Cemu (or
any other standalone emulator) can be launched directly from a real
multi-window XFCE session instead, using the exact same settings.xml the
next ES-DE-launched session will read.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 20:34:04 +00:00
Outis 5edfed7735 Merge pull request #424 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Fix TI-99/4A "emulator not found" by using a real es_find_rules.xml e…
2026-09-02 15:58:20 -04:00
Claude 910a49f12f Fix TI-99/4A "emulator not found" by using a real es_find_rules.xml entry
ES-DE's findEmulator() decides found-vs-not-found from the <command>
string's emulator token, and every real %INJECT%=...esprefix example in
ES-DE's own shipped es_systems.xml (Dolphin/PrimeHack/Triforce/Supermodel)
pairs it with an %EMULATOR_X%/%CORE_X% placeholder, never a literal path.
The previous ti994a <command> used a literal "/bin/bash -c ..." after
%INJECT%=%BASENAME%.esprefix, which ES-DE reported as "emulator not found"
even though /bin/bash obviously exists on the container.

Fix: give ti99sim-sdl a real custom_systems/es_find_rules.xml entry
(TI99SIM, staticpath ~/Applications/ti99sim-sdl) and reference it via
%EMULATOR_TI99SIM%, matching the pattern every built-in standalone
emulator uses. %STARTDIR%=%EMUDIR% replaces the old shell "cd && ..."
prefix to keep ti99sim-sdl's working directory at its own install dir,
where it looks up the console ROM via a plain relative path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 19:55:21 +00:00
Outis 22990b6583 Merge pull request #423 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
wolf: fix TI-99/4A ES-DE command line never actually updating on re-run
2026-09-02 15:38:02 -04:00
Claude b4ac5a4de0 wolf: fix TI-99/4A ES-DE command line never actually updating on re-run
Confirmed live: the command-line fix from an earlier commit (cd into
emulators/ before launching ti99sim-sdl) never reached the user's actual
es_systems.xml, because the write step's guard was "skip entirely if a
ti994a entry already exists" — which it did, with the old pre-fix
command line still in it. The check only asked whether an entry existed,
never whether its content matched the current template.

Now always strips any existing ti994a <system> block and re-adds the
current one fresh on every run, via a small python3 rewrite (regex block
removal + re-append) instead of a blind append-once guard. Tested against
a stand-in file matching the real stale content, and for idempotency
(second run doesn't duplicate the block), before pushing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 19:28:28 +00:00
Outis daf0ed11e4 Merge pull request #422 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-02 15:17:36 -04:00
Claude 0fe0c74235 wolf: fix Dolphin Sys folder writing to the wrong directory
Confirmed live: RetroArch's system_directory on this Wolf setup is
"~/bioses" (GoW's own shipped default, confirmed by reading a real
retroarch.cfg directly — wolf.sh never sets this itself), not RetroArch's
usual default of ~/.config/retroarch/system. The Dolphin Sys-folder
automation added earlier this session assumed the usual default and wrote
to retroarch/system/dolphin-emu/Sys on the host — a path RetroArch's own
config never actually reads, so it would have sat there doing nothing.

Now reads the real configured system_directory value out of
retroarch.cfg instead of assuming, and self-heals by moving a Sys folder
that's already sitting in the old wrong location (from before this fix)
into the correct one. Also corrects the same wrong assumption in the
MAME samples-directory documentation, which pointed at the same
retroarch/system/ default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 19:16:05 +00:00
Claude 2e7e073b63 wolf: fix TI-99/4A console ROM location + add it via a tab-completing prompt
The build succeeded, but the follow-up message pointed at "ti99sim-sdl's
own README" for where the console ROM goes — useless, since that source
tree lived inside the throwaway build container and was already gone by
the time anyone could read it.

Got the real answer from RetroPie's own configure_ti99sim(): it symlinks
the console ROM into the emulator's own install directory, then cd's
there before launching (pushd "$md_inst" && ./ti99sim-sdl "$@") — so
ti99sim-sdl finds it via a plain relative lookup, not any search path.
Caught a real bug this exposed: our own ES-DE <command> line launched
ti99sim-sdl by full path with no cd, so even a correctly-placed file
would never have been found. Fixed to cd into emulators/ first, matching
RetroPie's own pattern exactly.

Also, instead of just printing the required path (emulators/TI-994A.ctg)
and leaving it at that, now prompts for it directly with a tab-completing
path picker (read -e -i, defaulting into the game storage dir) — if you
already have the file somewhere, it copies it into place immediately
instead of making you do it by hand afterward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 19:11:13 +00:00
Outis f27fdad711 Merge pull request #421 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-02 14:58:43 -04:00
Claude e965c2bd76 wolf: build ti99sim-sdl from the real SDL2-native upstream, not a stale fork
You were right to push back on this earlier — the original ask was SDL2,
same as RetroPie, and I substituted a different thing (SDL1-compat) without
checking what RetroPie actually does first.

Checked RetroPie's own ti99sim.sh scriptmodule directly: it doesn't build
from billzajac/ti99sim (the GitHub fork the last two attempts used) at
all. It fetches upstream v0.16.0 straight from the original author's own
site (mrousseau.org), applies exactly one trivial patch (a missing
#include <cstring> for modern g++), and builds against libsdl2-dev +
libssl-dev directly — no SDL1 compatibility layer, because that source
genuinely supports SDL2 natively. The GitHub fork used previously was
just a stale, pre-SDL2-port copy under a different account.

Verified the download URL actually serves the real tarball (fetched real
binary content, confirmed the 7zXZ header) and the patch's exact content
before using either — same real-source-first standard as the wolf.sh
fixes earlier this session. Tested the sed insertion that replaces
applying the .diff against a stand-in file matching the patch's real
context before trusting it.

Also documents the exact BIOS-equivalent filename RetroPie's own docs
give for this emulator: TI-994A.ctg (case-sensitive) — a real, useful
detail that was missing before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 18:47:07 +00:00
Claude c7cc7176f0 wolf: fix ti99sim-sdl build — it needs real SDL 1.2, not SDL2
Confirmed live: the previous sdl-config shim got past the tooling
problem, but hit a much deeper one — ti99sim's SDL frontend
(src/sdl/main.cpp) is written against the actual SDL 1.2 API and uses
symbols SDL2 genuinely removed (SDL_keysym, SDL_WM_SetCaption,
SDL_EnableUNICODE, SDL_JoystickOpened, the old integer-index
SDL_JoystickName). No shim can paper over missing symbols — this needed
the real thing.

Swapped libsdl2-dev + the hand-rolled sdl-config shim for
libsdl1.2-compat-dev — the SDL project's own official compatibility
package (libsdl-org/sdl12-compat, packaged in Ubuntu's universe repo):
genuine SDL 1.2 headers and its own real sdl-config, implemented on top
of actual SDL2 underneath. The code compiles against the API it was
actually written for, while still running on modern SDL2 at runtime.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 18:40:55 +00:00
Outis ad5440a58b Merge pull request #420 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
wolf: fix ti99sim-sdl build failing on missing sdl-config
2026-09-02 14:36:20 -04:00
Claude a55f6c430a wolf: fix ti99sim-sdl build failing on missing sdl-config
Confirmed live: the containerized ti99sim-sdl build (previous commit)
failed with "SDL.h: No such file or directory" — not a real
compatibility problem, ti99sim's own Makefile.linux calls the old SDL 1.x
sdl-config tool, which doesn't exist in modern libsdl2-dev packaging at
all (only pkg-config .pc files). Every sdl-config invocation was silently
returning nothing, so the compile got no -I/-L flags whatsoever.

Adds a small shim mapping the handful of flags this build actually uses
(--cflags/--libs/--version, tested standalone against a fake pkg-config
before pushing) onto `pkg-config sdl2`, rather than patching the
project's own Makefiles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 18:32:35 +00:00
Outis 3b0689dfd9 Merge pull request #419 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
wolf: actually build ti99sim-sdl instead of punting it to a manual step
2026-09-02 14:28:41 -04:00
Claude 83d62b05fd wolf: actually build ti99sim-sdl instead of punting it to a manual step
Fixes a cop-out from the previous commit — rather than just warning about
a possible glibc/SDL2 mismatch and handing the user a manual build guide,
build ti99sim-sdl inside a throwaway container running the exact same
image ES-DE itself runs (ghcr.io/games-on-whales/es-de:edge), which
actually eliminates the mismatch risk instead of just noting it. Runs
during install_wolf() when the TI-99/4A prompt is accepted, skips itself
if the binary's already present, and drops the result straight into
emulators/ti99sim-sdl ready to use.

Caught a real bug before pushing, not after: `make` alone leaves the
binary under ti99sim's own src/ directory, not the repo root (confirmed
against its README), so a naive `cp ti99sim-sdl` would have failed
outright. Uses `make install` instead — the project's own reliable way to
collect the binary (copies to /opt/ti99sim/bin, symlinks into
/usr/local/bin) — rather than guessing the exact build subpath.

The one piece that's still genuinely a manual step, and stays that way:
supplying your own TI-99/4A console ROM + GROM dump, real copyrighted
console firmware no installer can legally source for you.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 18:22:40 +00:00
Outis 423b295acf Merge pull request #418 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
wolf: add Cemu (Wii U), TI-99/4A custom ES-DE system, BIOS/MAME-sampl…
2026-09-02 14:16:59 -04:00
Claude 25dc4251de wolf: add Cemu (Wii U), TI-99/4A custom ES-DE system, BIOS/MAME-samples docs
Cemu: no libretro core exists (a third-party attempt was never merged and
there are no plans to per RetroArch's own issue tracker) — added as a
fourth standalone AppImage download, same pattern as Azahar/PCSX2/Dolphin.
Its official release asset is already named Cemu-<version>-x86_64.AppImage,
matching ES-DE's own find-rule directly, so no Dolphin-style rename/symlink
is needed. Flags a real supply-chain compromise of Cemu's own Linux release
assets around v2.6 (2026-05, since restored, per Datadog Security Labs)
and that most retail games need a user-supplied Wii U common key.

TI-99/4A: added as a genuine ES-DE system (not this repo's existing
js99er service, which is browser-based and can't be launched as an ES-DE
system) via a custom_systems/es_systems.xml, per ES-DE's own documented
mechanism for extending its built-in system list. Needed a new mount
(esde-custom-systems -> ~/ES-DE/custom_systems) since that path wasn't
covered by any existing .config/.local/share mount. The emulator itself
(ti99sim-sdl) is deliberately NOT auto-built — it ships no AppImage, and
compiling it on the host risks a glibc/SDL2 mismatch against the ES-DE
container's own runtime that isn't safe to guess at blind — so this
writes the system definition (cheap, correct, always useful) and gives
manual build+placement instructions instead of a build automation I can't
verify actually runs.

Also expands the BIOS callout to mention PS2 (configured inside PCSX2's
own settings, not auto-detected from bios/) and TI-99/4A (needs a real
console ROM+GROM dump, same legal situation as any other BIOS), and
documents the MAME sample-pack-vs-ROM confusion (identical .zip naming
convention makes them indistinguishable by filename alone; samples belong
in retroarch/system/<mame-core>/samples/, not roms/mame/).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 18:04:16 +00:00
Outis 0a32ab7844 Update README to remove unnecessary exclamation
Removed redundant phrase 'Great!' from README.
2026-09-02 13:25:42 -04:00
Outis 24e59a280c Enhance README with positive remark
Updated README to include positive feedback.
2026-09-02 13:25:13 -04:00
Outis 253ee7587b Merge pull request #417 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
wolf: fix info.zip pack never actually installing (broke content matc…
2026-09-02 11:41:11 -04:00
Claude 505a342417 wolf: fix info.zip pack never actually installing (broke content matching for every core)
Confirmed live: RetroArch's file browser and directory-scan importer both
failed to recognize SNES ROMs (.sfc) even though the ROM files and the
snes9x core .so were both genuinely present and correctly mounted — the
core's own .info file (which declares supported_extensions) was never
actually installed, because the "already present, skip" check for the
info.zip pack was "is $CORES_DIR non-empty", and $CORES_DIR is always
non-empty by the time that check runs (the core .so files fill it first,
earlier in the same `cores all` run). So the info-pack fetch silently
no-op'd on every single install, fresh or not — not specific to this
laptop.

Give "info" its own real check: does $CORES_DIR actually contain any
*.info files, not just anything at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 15:38:51 +00:00
Outis ddfae32987 Merge pull request #416 from outis1one/claude/gitea-github-webhook-sync-7fkytg
Document the GitHub App option in the generated Gitea README
2026-09-02 08:53:31 -04:00
Claude 1b4036a0c2 Document the GitHub App option in the generated Gitea README
Adds Option B (a personal GitHub App installed with "All repositories"
access) alongside the existing per-repo webhook instructions, and
clarifies that Homepage URL and Webhook URL are separate fields on the
App creation form -- a real point of confusion when setting one up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016oxpDzv7qfV7RDvKHp1sPD
2026-09-02 12:50:14 +00:00
Outis a49f8c3533 Merge pull request #415 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-01 19:41:38 -04:00
Claude c9d1eac7f2 wolf: fix unbound-variable crash on a single-drive box with no unmounted disks
Confirmed live on a laptop with only its internal drive (no second/
unmounted disk at all): the game-storage-directory picker's `local -a
_UNMT_DEV _UNMT_LABEL _UNMT_UUID` declares the arrays but, when the lsblk
scan finds zero qualifying unmounted block devices, never actually assigns
an element to any of them. Under setup.sh's `set -u`, that's enough for a
later read (`${#_UNMT_DEV[@]}`) to throw "unbound variable" even though
the arrays were properly `local -a` declared — a known bash nounset quirk
this repo has already hit and documented once before (see
vendor/ai-stack/configure-searxng-safesearch.sh). Explicit `=()`
initializers side-step it. The sibling `_CAND_*` arrays a few lines above
don't need the same fix — they always get at least one element (the home
directory option is unconditional), so they can't hit this path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-01 23:41:15 +00:00
Claude 7ed376e9b7 wolf: auto-download shaders/overlays/cheats/database/autoconfig alongside cores
Extends `./manage.sh cores all` (and install_wolf()'s own cores prompt,
which calls it) to also pull the rest of what RetroArch's own Online
Updater offers — Slang shaders, overlays/bezels, cheat files, the RDB
game database, and controller autoconfig profiles — directly from the
same libretro buildbot the cores themselves come from
(buildbot.libretro.com/assets/frontend/). Confirmed live against the
real directory listing and each zip's actual internal structure (flat,
no wrapping folder) before writing the extraction paths, rather than
guessing: ~290 MB total, landing in retroarch/{shaders,overlays,cheats,
database,autoconfig}/ using RetroArch's own default paths for the three
config keys (cheat_database_path, content_database_path,
joypad_autoconfig_dir) this install leaves unset.

Thumbnails are deliberately excluded — they're hosted separately, are
per-system, and can run into many GB, so blindly grabbing "all systems"
would be a bad default; documented as a manual per-system pull via
RetroArch's own Thumbnails Updater instead.

Idempotent like the existing cores loop: skips a pack whose destination
directory already has content, unless `force` is passed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-01 22:29:05 +00:00
Outis 435992a4f0 Merge pull request #414 from outis1one/claude/gitea-github-webhook-sync-7fkytg
Allow typing a password when adding/resetting Authelia users
2026-09-01 15:44:57 -04:00
Claude 9d3801494a Allow typing a password when adding/resetting Authelia users
add_authelia_user() and the per-user "Reset password" action always
auto-generated a random password with no way to set a specific one.
Adds _authelia_prompt_password(), a shared masked-input prompt (same
"[Enter = auto-generate]" convention already used by backup.sh/
borg-backup.sh/koha.sh) that both call sites now use, so an admin can
type their own password or fall back to auto-generation as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016oxpDzv7qfV7RDvKHp1sPD
2026-09-01 19:26:29 +00:00
Outis eb794e61f9 Merge pull request #413 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
wolf: auto-fetch Dolphin's Sys folder, drop AntiMicroX, favor RetroAr…
2026-09-01 15:25:41 -04:00
Outis 5ace213bd4 Merge pull request #412 from outis1one/claude/gitea-github-webhook-sync-7fkytg
Add real-time GitHub webhook sync to Gitea
2026-09-01 15:24:34 -04:00
Claude 1d386e6a58 wolf: auto-fetch Dolphin's Sys folder, drop AntiMicroX, favor RetroArch core for GC/Wii
Confirmed live: the Dolphin libretro core needs its own 'Sys' folder
(compatibility DB + IPL data) to boot Wii titles, previously only
obtainable through a manual trip through RetroArch's Online Updater ->
Core System Files Downloader. ./manage.sh cores now fetches it
automatically (a sparse/shallow git checkout of dolphin-emu/dolphin's
Data/Sys) right after the core itself downloads, so a fresh install needs
no GUI step to get GameCube/Wii working.

Also documents the one Dolphin quirk that isn't installer-fixable: a
"This data is corrupted, delete and create a new one?" dialog with a
greyed-out OK button on a specific title (confirmed live with Mario Kart
Wii's own rksys.dat) is that game's own save data getting stuck, not a
setup defect — the fix is deleting that one file, which the README now
walks through directly instead of leaving it as a mystery.

Removes the AntiMicroX controller-combo plumbing entirely: RetroArch's own
universal hotkey binds already cover this for any core-based system
(GameCube/Wii and PS2 both now go through RetroArch's own cores rather
than standalone AppImages by default), and AntiMicroX grabbing the
controller device exclusively caused real problems (a stuck "controller
connected" screen) without enough benefit over RetroArch's native binds to
justify keeping it.

Reframes the GC/Wii/PS2 docs: RetroArch's cores are now the recommended
path (point ES-DE's Alternative Emulators at RetroArch instead of "Dolphin
(Standalone)"), with the standalone AppImages kept as a fallback and as
the only option for 3DS (Azahar has no libretro core at all).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-01 19:22:49 +00:00
Claude 778d06b0b8 Add real-time GitHub webhook sync to Gitea
Gitea's GitHub mirror sync previously only ran on a fixed-interval
timer. Adds an opt-in GitHub webhook receiver (small stdlib-only
Python HTTP server under its own systemd service) that verifies
GitHub's HMAC-SHA256 signature and triggers an immediate, single-repo
--pull-only sync the moment GitHub receives a push, wired through
Caddy the same way every other service in this repo is. The scheduled
timer stays in place as a safety net and still covers the Gitea ->
GitHub direction.
2026-09-01 16:27:11 +00:00
Outis 63faa9b1bd Merge pull request #411 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-01 11:06:39 -04:00
Claude 4199f42f70 wolf: fix wrong-architecture AppImage downloads (aarch64 picked on x86_64 hosts)
_wolf_download_emulator_appimage picked whatever release asset happened to
be first in the GitHub API's asset list among files ending in .AppImage,
with no architecture filtering. Confirmed live: pkgforge-dev's Dolphin
AppImage release lists an aarch64 build ahead of the x86_64 one, so the
downloaded file silently failed at launch with a bare "exec format error"
and no indication why — the earlier "emulator not found" symlink fix
(previous commit) got ES-DE to find the file at all, which is what
surfaced this as the next failure.

Now prefers whichever release asset's filename actually tags the host's
own architecture, falls back to an untagged asset (Azahar/PCSX2's releases
carry no arch tag and are unaffected either way), and only then falls back
to "take the first one". Also verifies the downloaded file's real ELF
architecture against the host post-download and warns loudly if it still
doesn't match, since filename tagging isn't something every release can be
trusted to get right.
2026-09-01 15:03:57 +00:00
Claude 2c51ab5faa wolf: symlink downloaded Dolphin AppImage to the name ES-DE actually looks for
ES-DE's es_find_rules.xml DOLPHIN entry only auto-detects a file literally
named Dolphin_Emulator*.AppImage under ~/Applications. pkgforge-dev's own
community-build release asset isn't named that, so the AppImage downloaded
by install_wolf() sat in emulators/ unnoticed and ES-DE reported "Couldn't
launch game, emulator not found" (%EMULATOR_DOLPHIN% unresolved) even
though the file was right there — confirmed live on a real box.

Drop a same-directory Dolphin_Emulator.AppImage symlink pointing at
whatever the real download is named, without renaming/losing the vendor
filename. Runs unconditionally (not just after a fresh download) so it
also repairs installs that grabbed the file before this fix existed.
2026-09-01 14:37:01 +00:00
Outis 6dff335ac7 Merge pull request #410 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Fix wolf.sh: mount /home/retro/.local/share, not just .config
2026-09-01 09:52:49 -04:00
Claude 85b13d07a7 Fix wolf.sh: mount /home/retro/.local/share, not just .config
Confirmed live: Dolphin's "data is corrupted" / "could not write
to/read from Wii system memory" was ~/.local/share/dolphin-emu (the
emulated Wii NAND) not existing at all inside the container —

  ls: cannot access '/home/retro/.local/share/dolphin-emu/':
  No such file or directory

Only /home/retro/.config had a persistent mount (retro-home, added
earlier this session for the RetroArch permission-denied fix) —
/home/retro/.local/share, the other half of the XDG base directory
split many apps use (config vs. actual data), was never addressed and
started completely empty on every container launch. Any NAND state
Dolphin wrote mid-session vanished the moment that container was torn
down, which is consistent with "corrupted" rather than merely "empty"
if a session ended mid-write.

Same fix as the earlier .config mount: a new retro-home-data host
directory mounted at /home/retro/.local/share in both the esde and
retroarch app profiles (both CATALOG copies), forced to 1000:1000
ownership like the other retro-home dirs since the container's retro
user is hardcoded to that uid regardless of the host account.
2026-09-01 13:49:05 +00:00
Outis ce9a8e8c5b Merge pull request #409 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-08-31 22:45:22 -04:00
Claude bef88e654d Fix two bugs in the AntiMicroX plumbing before it ever runs
1. Glob case mismatch that would have silently no-op'd the whole
   feature: the real GitHub release asset is AntiMicroX-x86_64.AppImage
   (capitalized) — confirmed against the actual release, not assumed.
   Both the helper's own already-downloaded check and this file's own
   post-download lookup used a lowercase-only "antimicrox*.AppImage"
   glob (copied from Azahar's/PCSX2's pattern, which really are
   lowercase). Linux glob matching is case-sensitive, so _AMX_APP would
   have come back empty even after a successful download, hitting the
   "didn't produce a usable AppImage" branch and silently skipping the
   Sway launch hook with the AppImage sitting there unused.

2. Sway match criteria used app_id, which only matches native Wayland
   clients. AntiMicroX is a Qt5/X11 app running under XWayland here —
   same as ES-DE and Steam already are in this exact stock config,
   which is why their own for_window rules use [class="..."], not
   app_id. Switched to class (and title as a fallback, in case the
   exact WM_CLASS AntiMicroX registers differs), with a (?i) case-
   insensitive prefix — confirmed sway uses PCRE2 for criteria matching
   and (?i) is the documented syntax for exactly this, not assumed.

Neither bug was fatal to Wolf itself (both fail quietly rather than
crashing anything), but both would have cost real troubleshooting time
for something that looked like it should just work.
2026-09-01 02:32:45 +00:00
Claude f087984526 Add AntiMicroX plumbing for a universal controller-combo hotkey
Wires up the infrastructure for RetroArch's own hotkeys (global across
every libretro core already) to be matched by an equivalent for
Dolphin/PCSX2/Azahar, none of which support gamepad-bound hotkeys
natively (confirmed open feature requests: PCSX2/pcsx2#1082,
azahar-emu/azahar#722; Dolphin's are hardcoded to keyboard only).

AntiMicroX watches Wolf's virtual gamepad directly — a real uinput
device per Wolf's own "inputtino" docs, not a proprietary channel —
and injects whatever keyboard shortcut the focused emulator actually
expects.

Mechanism: GoW's own stock Sway config (baked into the esde/retroarch
images) has a deliberate first-line extension point,
`include /home/retro/.config/sway/custom-cfg` (confirmed by reading it
directly out of a running container). Since /home/retro/.config is
already the retro-home mount added earlier this session, writing a
file to $GAME_STORAGE_DIR/retro-home/sway/custom-cfg on the host lands
exactly there in both containers — zero changes to GoW's own image or
scripts needed.

Deliberately scoped to plumbing only: download AntiMicroX (reusing the
_wolf_download_emulator_appimage helper added for PCSX2/Dolphin), add
the Sway launch hook (floating window, not hidden, so it's reachable
through the Moonlight stream), and document the actual one-time setup
flow in the generated README. The button mapping itself, including
AntiMicroX's own per-application Auto Profile switching, is built
through its real GUI rather than a hand-authored .gamecontroller.amgp
this repo can't verify blind.

Marked experimental throughout (install prompt defaults to n, DRY-RUN
summary, README) — this is unverified against a live session.
2026-09-01 02:24:29 +00:00
Claude 1ed7fe89ea Add PCSX2 and Dolphin to wolf.sh's auto-download emulators
Factored the existing Azahar-only download block into a reusable
_wolf_download_emulator_appimage() helper (same fetch-latest-release-
and-find-.AppImage-asset logic, parameterized), then reused it for:

- PCSX2 (PS2) — officially publishes Linux AppImages via GitHub
  Releases (PCSX2/pcsx2), same pattern as Azahar.
- Dolphin (GameCube/Wii) — dolphin-emu.org's own Linux distribution
  is Flatpak-only, no official AppImage at all. Uses the well-regarded
  community AppImage build (pkgforge-dev/Dolphin-emu-AppImage)
  instead, with explicit warnings before the prompt and in the
  generated README that this is a third-party build, not an official
  Dolphin release — so the user can decide knowingly rather than this
  silently substituting an unofficial build for what looks like an
  official option.

Motivated by a live "could not write to/read from Wii system memory"
error — GameCube/Wii isn't handled by a RetroArch libretro core in any
stable way (Dolphin's libretro core is unstable for Wii specifically),
so ES-DE needs a real standalone Dolphin binary to hand those systems
off to, the same way it already does for 3DS via Azahar.

Also updated the DRY-RUN summary and the generated README's emulators
section to describe all three instead of just Azahar.
2026-08-31 23:44:24 +00:00
Outis 7dfcb8272a Merge pull request #408 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Fix wolf.sh: mount /home/retro/.config itself, not just .config/retro…
2026-08-31 18:03:32 -04:00
Claude 0ba83212d1 Fix wolf.sh: mount /home/retro/.config itself, not just .config/retroarch
Only .config/retroarch was mounted, so Docker still had to auto-create
.config itself (one level up) as an intermediate directory for that
bind mount to attach under — same root:root mode-755 stub problem as
before, just one level higher. Confirmed live: RetroArch got past its
own config.toml write this time, but then crashed on:

  mkdir: cannot create directory '/home/retro/.config/waybar': Permission denied

(Waybar/Sway — the on-screen overlay GoW's app-runner wrapper spawns
for every app, not RetroArch-specific.)

Fix: add a second mount at /home/retro/.config itself, backed by a new
retro-home/ host directory, alongside the existing more-specific
.config/retroarch mount (Docker resolves nested/overlapping bind mounts
correctly regardless of list order). Now nothing under .config is a
Docker-auto-created stub — it's real host-backed content throughout,
so anything an app writes directly under .config (Waybar, Sway state,
RetroArch's own non-cores/shaders/overlays files) just works.

Also force retro-home/ and the existing retroarch/ dir to 1000:1000
explicitly after the general ACTUAL_USER chown — the GoW app
containers' 'retro' user is hardcoded to uid 1000 regardless of what
uid the host account installing this actually has, so relying on the
general chown alone silently breaks this fix on any box where those
don't match.
2026-08-31 21:21:53 +00:00
Outis d72c638337 Merge pull request #407 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Fix wolf.sh update_field(): scope field search to the app's own block
2026-08-31 16:46:01 -04:00
Claude ed939e5826 Fix wolf.sh update_field(): scope field search to the app's own block
update_field() searched a blind +/-25-line window around an app's
name = '...' line to find and rewrite its mounts/env field. Once app
blocks got shorter (e.g. after collapsing a 3-line mounts array into
one line), two adjacent apps' blocks could end up close enough that
the window reached into a neighboring app's block instead — splicing
that block's own field or, worse, eating into its
[profiles.apps.runner] table header.

Confirmed live: this corrupted config.toml into invalid TOML and
crash-looped Wolf outright:

  ERROR | Unhandled exception: Error while parsing table header:
  cannot redefine existing table 'profiles.apps.runner'

Fix: bound the search to the enclosing [[profiles.apps]] block only —
walk backward from the name line to the nearest [[profiles.apps]]
header, forward to the next [[profiles.apps]] or [[profiles]] header,
and only look for the field within that range. Never crosses into a
neighboring block regardless of how short either one is.
2026-08-31 20:45:18 +00:00
Outis bc7b9c6bb0 Merge pull request #406 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Fix wolf.sh manage.sh apps: detect installed apps regardless of inden…
2026-08-31 16:24:48 -04:00
Claude c1a97d8945 Fix wolf.sh manage.sh apps: detect installed apps regardless of indentation
INSTALLED=$(sudo grep "^    name = 'Wolf" ...) required exactly 4 leading
spaces before name = 'Wolf...' in Wolf's own generated config.toml.
Confirmed live: Wolf's TOML writer doesn't reliably indent that way, so
this came back empty even with apps clearly installed and running,
printing "No Wolf apps installed yet". That's silently wrong in two
ways: the "Already installed: ..." message under-reports, and pressing
Enter at the apps prompt ("update mounts only") falls through to the
hardcoded `steam esde` default instead of actually refreshing whatever
was already there — so a mount fix for an already-installed app (e.g.
RetroArch) never gets applied by the Enter/no-picks path at all.

Relaxed the anchor to tolerate any amount of leading whitespace,
matching the Python injector's own already-installed check a few lines
later, which never anchored on indentation to begin with.
2026-08-31 20:21:36 +00:00
Outis 0d8d87794d Merge pull request #405 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-08-31 16:11:29 -04:00
Claude 76a494bcbf Fix wolf.sh: mount whole retroarch/ dir, not just its subdirectories
The esde and retroarch app profiles (both CATALOG dict copies) mounted
three subdirectories individually:

  {games}/retroarch/cores:/home/retro/.config/retroarch/cores:rw
  {games}/retroarch/shaders:/home/retro/.config/retroarch/shaders:rw
  {games}/retroarch/overlays:/home/retro/.config/retroarch/overlays:rw

but never mounted /home/retro/.config/retroarch itself. Docker
auto-creates that missing parent directory inside the container as
root:root mode 755 (standard behavior for a bind-mount target that
doesn't already exist in the image) — the retro user (uid 1000) can
read/traverse it but not write into it. RetroArch's own entrypoint
then fails outright trying to write its default config there:

  cp: cannot create regular file '/home/retro/.config/retroarch/retroarch.cfg': Permission denied

which happens on every single launch, for both apps — confirmed live
against a real box: the container starts, RetroArch dies on that cp
within ~1s, and Wolf tears the session down (the "black screen, back
to app grid" symptom, with nothing RetroArch-specific about it).

Fix: mount the parent {games}/retroarch directory itself onto
/home/retro/.config/retroarch instead of three separate subdirectory
mounts. cores/shaders/overlays already lived as the only subdirectories
under {games}/retroarch/ on the host, so this preserves the exact same
container-side paths — but now the parent is a real bind mount with no
auto-created stub in the way, and RetroArch's other generated config
(button remaps, core options, playlists, cheats, etc.) persists across
sessions too, which the old three-mount setup silently discarded.
2026-08-31 20:09:33 +00:00
Claude 3ffcde7294 Fix gitea-github-sync.sh: stop mirroring refs/pull/* into Gitea/GitHub
Both sync directions used `git clone --bare` for the first clone and
`git push --mirror` for the push. A bare clone pulls in every ref the
remote advertises, refs/pull/*/head included — GitHub (and Gitea,
same behavior) exposes PR refs over the same smart-HTTP endpoint a
plain bare clone reads from. --mirror then pushes every local ref
verbatim, including those, and gets rejected: Gitea's server-side
hook (and GitHub's own PR-ref protection) reserves that namespace for
itself.

  remote: error: hook declined to update refs/pull/1/head
  ! [remote rejected] refs/pull/1/head (hook declined)

Fix: scope the initial clone (now git init --bare + fetch, unified
with the repeat-sync path instead of a separate git-clone branch) and
the push to an explicit refs/heads/*:refs/heads/* + refs/tags/*:refs/tags/*
refspec in both directions, matching the refspec discipline the fetch
side already had. Added a defensive cleanup (delete any
refs/pull/*, refs/merge-requests/*, refs/changes/* found in the local
bare mirror before pushing) so a repo synced before this fix
self-heals on its next run instead of tripping the same hook forever.
2026-08-31 18:51:53 +00:00
Outis a33fb0fd84 Merge pull request #404 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Add samba service: shares, dedicated users, LAN-scoped firewall
2026-08-31 14:45:39 -04:00
Claude f8bfce87d9 Add samba service: shares, dedicated users, LAN-scoped firewall
New services/samba.sh, following the non-Docker service shape
(services/crowdsec.sh) since Samba runs natively (smbd/nmbd), not in
a container:

- Installs the samba package if missing
- Prompts to add one or more shares (path, guest vs. authenticated)
- For authenticated shares, creates a system Linux account (if one
  doesn't already exist) and a separate Samba password via smbpasswd
  for each user, adds them to a sambashare group
- Appends share stanzas to /etc/samba/smb.conf (tagged with a
  # ubuntu-post-install:share:<name> marker for later discovery),
  validates with testparm before restarting smbd/nmbd
- Opens UFW for SMB (137/138 udp, 139/445 tcp), scoped to the
  detected LAN subnet by default rather than the whole internet
- Writes a docs-only README under ~/docker/samba (no compose stack)

Registered under `utilities`, with an is_installed()/install_count()
entry in setup.sh (command -v smbd, matching the glow/crowdsec
pattern for non-Docker services) and a README.md Services table entry.

Also wired as an optional nudge into services/base.sh, alongside the
existing Caddy/CrowdSec/NetBird prompts — offered during the base
install but not unconditional, since (unlike net-tools/ncdu) it needs
real input — a share path and at least one user — to do anything
useful, so it defaults to declined rather than accepted.
2026-08-31 18:39:11 +00:00
Outis c584bc45cd Merge pull request #403 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-08-31 14:19:09 -04:00
Claude 591bdd0e79 Fix gitea.sh: open the SSH clone port in UFW
install_gitea() scanned WEB_PORT/SSH_PORT and published both in
docker-compose.yml but never opened either in UFW. With UFW active,
a `git clone ssh://...` against the SSH port silently drops instead
of getting connection-refused, which just hangs forever with no
error — the exact symptom reported.

The web port can be safely left off the public rule when Caddy fronts
it locally (scoped to caddy_net instead, matching every other service
here), but SSH can't be proxied through Caddy at all, so it always
gets a direct ufw allow now.
2026-08-31 18:01:43 +00:00
Claude 8a298d161a Fix wolf-pair: scan for a free port instead of hardcoding 8090
wolf-pair runs network_mode: host, so a taken 8090 fails at container
start with "address already in use" and no ports: line in
docker-compose.yml to explain why — wordpress, ntfy, and beszel all
default to 8090 too and correctly scan for a free port; wolf-pair
hardcoded it in three places (installer var, UFW rule, server.py's
bind) with no scan at all.

Now finds a free port via find_free_port, persists it in a new .env
(read back on rerun so a live install never silently moves), and
threads it into the container via WOLFPAIR_PORT so server.py binds
the scanned port instead of a literal 8090.
2026-08-31 17:44:22 +00:00
Outis 63eab19e9c Merge pull request #402 from outis1one/claude/pressbooks-authelia-setup-tcryml
Fix pressbooks.sh: move proxy/Prince/DocRaptor config out of wp-confi…
2026-08-31 09:10:34 -04:00
Claude 5e281e3d11 Fix pressbooks.sh: move proxy/Prince/DocRaptor config out of wp-config.php
WP-CLI's Runner does its own restricted, line-level parsing of
wp-config.php to pull bootstrap constants without a full WordPress load,
and it only tolerates plain define(...) statements — the previous fix's
"if (file_exists(...)) { require ...; }" line (routed in via
WORDPRESS_CONFIG_EXTRA) made every wp-cli command fail with a cryptic
"PHP Parse error ... eval()'d code ... unexpected end of file",
regardless of whether the required file actually existed.

Moved the X-Forwarded-Proto shim and the PB_PRINCE_COMMAND/
DOCRAPTOR_API_KEY defines into a WordPress must-use plugin
(wp-content/mu-plugins/), which loads through WordPress's normal plugin
bootstrap rather than wp-cli's special wp-config.php pre-parser — this
sidesteps both that bug and the earlier Compose .env-interpolation bug
in the same stroke, since nothing here touches wp-config.php or .env at
all anymore. Dropped the now-unnecessary extra-config.php bind mount
from the compose file and every wp-cli invocation to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P1Xynq3mBwtH45f8bTDfta
2026-08-31 02:40:23 +00:00
Outis d0953890e6 Merge pull request #401 from outis1one/claude/pressbooks-authelia-setup-tcryml
Fix pressbooks.sh: wp-cli commands failing with "core: not found"
2026-08-30 22:32:30 -04:00
Claude a4d33f6afd Fix pressbooks.sh: wp-cli commands failing with "core: not found"
wp-config.php's WORDPRESS_CONFIG_EXTRA requires extra-config.php, but the
ephemeral "docker run wordpress:cli ..." containers used for every wp-cli
call only mounted html/, not that file — loading wp-config.php there hit
a PHP fatal, which broke the wordpress:cli entrypoint's own internal
"wp help $1" probe for whether to prepend "wp". That probe failing
silently falls through to exec-ing the raw subcommand as a literal binary
("core: not found") instead of running it through wp-cli at all.

Fixed by: bind-mounting extra-config.php into every wp-cli invocation too,
guarding the require with file_exists so a missing mount can't fatal
wp-config.php again, and spelling "wp" out explicitly in the wpcli
wrapper functions rather than depending on the entrypoint's own
bootstrap-dependent auto-detection. Updated the manual-retry command
printed on failure and the README's wp-cli example to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P1Xynq3mBwtH45f8bTDfta
2026-08-31 02:27:30 +00:00
Outis 0f0740a322 Merge pull request #400 from outis1one/claude/pressbooks-authelia-setup-tcryml
Fix pressbooks.sh: docker compose up never ran after a successful build
2026-08-30 22:20:56 -04:00
Claude 28d6d8faf4 Fix pressbooks.sh: docker compose up never ran after a successful build
"if ! docker compose build && docker compose up -d" only negates the
build command's exit status, so on a normal successful build the whole
&&-chain short-circuited false and docker compose up -d never executed —
no containers started, no pressbooks_net network created, so every
following wp-cli call ("docker run --network pressbooks_net ...") failed
with "network pressbooks_net not found". Split into two separate checks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P1Xynq3mBwtH45f8bTDfta
2026-08-31 02:19:23 +00:00
Outis 42072387f8 Merge pull request #399 from outis1one/claude/pressbooks-authelia-setup-tcryml
Add Pressbooks: self-hosted book platform on WordPress Multisite
2026-08-30 19:01:08 -04:00
Outis 165f3d3ecd Merge pull request #398 from outis1one/claude/frigate-authelia-openid-0l1htj
Claude/frigate authelia openid 0l1htj
2026-08-30 19:00:40 -04:00
Claude 697ee95461 Add Pressbooks: self-hosted book platform on WordPress Multisite
Dedicated WordPress Multisite install (never shared with wordpress.sh,
since Pressbooks requires a fresh network) with a custom image adding
mod_rewrite/AllowOverride, Ghostscript/ImageMagick/poppler-utils for the
cover generator, and an optional PrinceXML install for PDF export
(DocRaptor offered as a SaaS alternative). Chapters use WordPress's own
block editor, which already supports drag-and-drop image placement.
Gated by Authelia SSO via the standard forward_auth pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P1Xynq3mBwtH45f8bTDfta
2026-08-30 21:38:32 +00:00
Claude 07a6786ea5 Document manual/API fallback for Homebox's missing entity types (#1593)
services/homebox.md gets auto-appended to Homebox's generated README
by write_readme(). Covers the installer's own opt-in fix plus a fully
manual UI walkthrough and a direct curl/API path, for anyone who'd
rather not paste a token into the installer or who's confirmed a
multi-collection setup isn't worth chasing through it.

UI navigation (collection selector -> Collection options -> Entity
Types tab, /collection/entity-types) confirmed against Homebox's own
frontend source rather than guessed.
2026-08-28 11:10:07 +00:00
Claude fe9ff46081 Add opt-in fix for Homebox's missing default entity types (#1593)
Some Homebox collections never get their default Location/Item entity
types seeded (a known upstream bug), leaving the Create dialog's type
dropdown empty and every creation attempt failing with "Please select
an entity type".

_homebox_offer_entity_type_fix() repairs this without ever storing a
credential: entity types are scoped per collection with no
unauthenticated API access, so it prompts for a pasted API token at
the moment it runs (used once, never written to .env or disk, same
model as Immich's own admin-API-key prompt), then seeds the two
default types only if none already exist. Wired into both the
fresh-install and update paths.
2026-08-27 13:19:09 +00:00
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
Outis 60b5dd5f29 Merge pull request #360 from outis1one/claude/gitea-standalone-setup-oxoi2e
Claude/gitea standalone setup oxoi2e
2026-08-20 11:40:40 -04:00
Claude 2b6c06e060 authelia: automate "remember me" duration, fix stale config key in docs
Fixes two things found while answering a question about staying logged
in across every Authelia-protected service:

1. CLAUDE.md's own "stay logged in" instructions referenced
   remember_me_duration — renamed to remember_me in Authelia 4.38, this
   repo pins 4.39.20. Authelia doesn't error on an unknown key, it just
   silently ignores it, so following that guidance as written would have
   done nothing. install_authelia() itself already uses the correct
   `remember_me` key at install time (default 7d) and was never affected
   — only the hand-edit instructions in the docs were stale.

2. There was no way to change it afterward without hand-editing the file,
   contrary to this repo's own "no manual config editing" direction.
   Added _authelia_set_remember_me() (new menu option 7): prompts for a
   new duration (12h/7d/1M/1y/-1 to disable), writes it, restarts.

Tested the sed replacement against a synthetic session block before
trusting it on real config. Also documented clearly (both in the
function's own prompt and in CLAUDE.md) that this only controls
Authelia's own session — a native-OIDC app's own session/token expires
on its own separate schedule, which this setting doesn't touch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEQNc4NfBST1m9NtCZVYa8
2026-08-19 20:37:09 +00:00
Claude 699be5b4e3 authelia: make OIDC client registration self-healing on a stale client_id
Confirmed live: ActualBudget's new automated "Sign in with Authelia" offer
hit a client_id ("actualbudget") already registered from an earlier use of
the interactive "Register an app" menu — that older flow only registers
the client in Authelia and prints instructions to paste the secret into
the app's own settings manually; if that paste step never happened,
ActualBudget's .env never got the OIDC vars, but Authelia still considered
the client_id taken. _authelia_provision_oidc_client's duplicate check
just warned and returned 1, permanently blocking the automated offer with
no path forward — the stale registration's secret was shown once and
already gone, so there was nothing to recover, only reasons to replace it.

Added _authelia_remove_oidc_client() (tested against a synthetic
multi-client config, both mid-list and last-in-list removal) and changed
the duplicate-client_id check to remove-and-replace instead of failing.
Every automated caller (gitea/mealie/actualbudget's SSO offers) uses a
fixed, service-specific client_id, so a collision there means "this same
service was already registered," not a different app's ID being
clobbered. The interactive menu's own earlier duplicate check (a distinct
code path, one step before this one) is untouched — it still warns and
stops before prompting further, since a human-typed ID colliding with an
unrelated app is a different, more ambiguous situation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEQNc4NfBST1m9NtCZVYa8
2026-08-19 19:11:34 +00:00
Claude 3dca8dce1d mealie, actualbudget: add native "Sign in with Authelia" (OIDC)
Researched which of the "has built-in auth" services actually support
native OIDC before wiring anything in (checked live docs, not assumed) —
two services turned out to contradict general assumption: Portainer's
OAuth/OIDC is Business Edition only (this repo installs portainer-ce, which
doesn't have it), and ntfy has no auth-oauth2-* support at all despite it
seeming like the kind of thing a modern self-hosted tool would have added
by now. Full findings recorded in CLAUDE.md so this doesn't need
re-researching.

Two real, verified wins wired up, both entirely env-var driven — no
manual config file editing, matching this repo's "no manual wizard"
philosophy and reusing the exact _authelia_provision_oidc_client /
_authelia_scope_access machinery already built for Gitea:

- mealie: OIDC_AUTH_ENABLED/OIDC_CLIENT_ID/OIDC_CLIENT_SECRET/
  OIDC_CONFIGURATION_URL appended to the existing .env (env_file: .env is
  already how mealie.sh's compose reads it). Also adds a
  --forwarded-allow-ips entrypoint override when Caddy-fronted — confirmed
  against Mealie's own issue tracker that without it, the generated OIDC
  redirect URI comes out http:// even when actually served over https://,
  which providers reject as a scheme mismatch.
- actualbudget: ACTUAL_OPENID_DISCOVERY_URL/CLIENT_ID/CLIENT_SECRET/
  SERVER_HOSTNAME, same pattern. Redirect path (/openid/callback) matches
  the preset already used by authelia.sh's own "Register an app" menu for
  this same service.

Both offered on fresh installs and Update reruns, default no, and both
call _authelia_scope_access() afterward so access can be restricted to
specific users instead of every Authelia user, same as Gitea.

Immich has real OIDC + a system-config API but needs one more
verification pass on the exact request payload before automating — not
guessing that part. Jellyfin and Home Assistant only have third-party
plugin/HACS-based OIDC, a bigger lift than an env-var toggle — noted but
not attempted this pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEQNc4NfBST1m9NtCZVYa8
2026-08-19 18:44:00 +00:00
Claude 393f97d164 Merge branch 'claude/ionos-script-integration-x32ofw' into main
Add PJSIP transport-tls keep_alive_interval to mitigate IONOS WiFi
client disconnects.
2026-08-19 02:56:49 +00:00
Claude a12aae7819 asterisk: add PJSIP transport-tls keep_alive_interval for IONOS WiFi drops
Sipnetic clients on the user's home WiFi/VLAN lose SIP/TLS registration
every ~5s when Asterisk runs on IONOS, but never on DigitalOcean or over
mobile data — CrowdSec, OPNsense firewall/IDS, TURN-for-registration, and
raw packet loss have all been ruled out via live testing. Leading theory
is an idle-connection timeout inside IONOS's network virtualization layer.
keep_alive_interval sends a periodic double-CRLF over the TLS transport to
keep it from going idle, the standard mitigation for this failure class.

Follows the existing dual-patch pattern (vendor-template copy + live file)
since transport objects aren't picked up by `pjsip reload` and need a
container restart to apply, same as the live_dangerously fix.
2026-08-19 02:56:34 +00:00
Outis d1a67766d7 Merge pull request #359 from outis1one/claude/gitea-standalone-setup-oxoi2e
gitea-github-sync: pin fetch refspec explicitly, stop silencing stderr
2026-08-17 22:21:11 -04:00
77 changed files with 9714 additions and 461 deletions
+233 -42
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:
- **Don't rename a live install's directory or containers.** New installs
land in `~/docker/asterisk` with `easy-asterisk`; a pre-merge droplet keeps
`~/docker/asterisk-digital-ocean` and `easy-asterisk-do`, because its
Caddyfile block, UFW rules, Cloud Firewall, CrowdSec acquisition and PSTN
trunk all name those exact paths. `_asterisk_resolve_layout()` picks
whichever exists, and every sibling service probes both.
land in `~/docker/asterisk` with a container named `asterisk`; a pre-merge
droplet keeps `~/docker/asterisk-digital-ocean` and `easy-asterisk-do`,
because its Caddyfile block, UFW rules, Cloud Firewall, CrowdSec
acquisition and PSTN trunk all name those exact paths.
`_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.**
The Asterisk security-logging patch and the `logs/full` logrotate config
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
```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
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
for `import authelia` or custom matchers).
is an optional string inserted verbatim inside the Caddy site block, before
`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
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`
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
caller can tell whether Caddy actually ended up fronting the service:
Sets three out-params (not `local` — read them after the call returns) so
the caller can tell whether Caddy actually ended up fronting the service:
```bash
CADDY_SERVICE_CONFIGURED # true/false
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
fronts *locally* (it reaches the service over `host.docker.internal`, not
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
logic again.
**Scoping a domain to specific users instead of every Authelia user.**
By default, any domain with an `access_control` rule at all is reachable by
every Authelia user (the existing catch-all `*.${AUTHELIA_DOMAIN}` rule).
`services/authelia.sh`'s `_authelia_scope_access(SERVICE_ID, DOMAIN)` is a
generic, reusable opt-in on top of that — call it right after *any* service
finishes being protected by Authelia, forward_auth gate or native OIDC
alike (it only cares about the domain, not the gating mechanism; see
`_gitea_offer_authelia_sso()` for the reference caller). Asks whether
access should stay universal or be scoped to specific usernames; if scoped,
creates a dedicated `<service_id>-only` group, adds every listed username
to it (creating accounts on the fly via
**Internal vs. outside access — named, reusable groups, not one group per
service.** By default, any domain with an `access_control` rule at all is
"internal": reachable by every Authelia user (the existing catch-all
`*.${AUTHELIA_DOMAIN}` rule) — admins included automatically, since the
admin-bypass rule (below) always outranks it anyway. `services/authelia.sh`'s
`_authelia_scope_access(SERVICE_ID, DOMAIN)` is the generic, reusable opt-in
on top of that for "outside access" — call it right after *any* service
finishes being protected by Authelia, forward_auth gate or native OIDC alike
(it only cares about the domain, not the gating mechanism; see
`_gitea_offer_authelia_sso()` for the reference caller). Asks "Internal
(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,
printing their temp password), and inserts two rules *above* the general
catch-all — allow that group on this domain, deny that group on every
other protected domain. Idempotent: reruns against an already-scoped
domain just report the existing group instead of duplicating rules.
printing their temp password) and inserts two rules *above* the general
catch-all but *below* the admin-bypass rule — allow that group on this
domain, deny that group on every other protected domain. The already-scoped
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
helper above — a service can run standalone with authelia.sh never sourced.
@@ -416,18 +475,34 @@ universal by removing them from their `-only` group(s) — a pure users.yml
edit, since universal access is just the *absence* of a restricting group,
not a rule of its own.
Only `services/gitea.sh` calls `_authelia_scope_access()` so far (the
reference integration). The other services that already offer a plain
"Protect X with Authelia SSO?" prompt (`magicmirror`, `wolf-pair`,
`js99er`, `drum-rhythm-game`, `iopaint`, `paintplus`, `stirling-pdf`,
`wolf`) are natural, mechanical follow-ups — each just needs one added
call to `_authelia_scope_access` after its existing
`services/gitea.sh`, `services/mealie.sh`, and `services/actualbudget.sh`
call `_authelia_scope_access()` so far. The other services that already
offer a plain "Protect X with Authelia SSO?" prompt (`magicmirror`,
`wolf-pair`, `js99er`, `drum-rhythm-game`, `iopaint`, `paintplus`,
`stirling-pdf`, `wolf`) are natural, mechanical follow-ups — each just
needs one added call to `_authelia_scope_access` after its existing
`configure_caddy_for_service` step, once Gitea's integration has been
confirmed working live. Extending *native* OIDC support (the pattern
above, not just scoping) to the "has built-in auth" services beyond Gitea
needs verifying per service first — not every app in that list actually
has its own OAuth2/OIDC provider field, so don't assume one exists without
checking that service's real settings.
confirmed working live.
**Native OIDC support across the "has built-in auth" list — checked
against each app's real docs (2026-08), not assumed.** Don't extend this
pattern to a service without checking its own current settings first —
two of the ones below turned out to need actual verification to get
right (Portainer, ntfy), not general familiarity with the product:
| 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. |
| `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"`. Separately, some Homebox collections hit an unrelated upstream bug (sysadminsmedia/homebox#1593): the default `Location`/`Item` entity types never get seeded for that collection, so the Create dialog's type dropdown comes up empty and creation fails with "Please select an entity type" regardless of Authelia. `_homebox_offer_entity_type_fix()` is the opt-in repair: entity types are scoped per collection with no unauthenticated read/write (confirmed against Homebox's own swagger doc — `GET`/`POST /v1/entity-types` both require a bearer token, and there's no documented endpoint to switch a token between a user's collections), so rather than baking in or storing any credential it prompts for a pasted API token at the moment it runs — same one-time, never-persisted trust model as `_immich_offer_authelia_oidc()`'s own admin-API-key prompt — checks for an existing `isLocation:true` type, and POSTs the two defaults only if none exist. Explicitly told upfront that fixing it only covers the one collection that token's account belongs to; a multi-collection user has to repeat the step once per collection. |
| `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 — 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. |
| `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. |
| `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`, `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:**
`magicmirror`, `wolf-pair`, `js99er`, `drum-rhythm-game`, `iopaint`,
@@ -448,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
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
`configure_caddy_for_service` and pass `import authelia` as the extra block
if Authelia is installed and the user wants SSO protection:
@@ -463,22 +612,64 @@ configure_caddy_for_service "MagicMirror" "8081" "mirror" "$EXTRA_BLOCK"
```
**Authelia "stay logged in" / kiosk mode:**
Edit `~/docker/authelia/config/configuration.yml` and set a long
`remember_me_duration`. Users then check "Remember me" once on login and
the session persists through reboots (Redis stores the session in a volume):
`install_authelia()` already writes `remember_me: 7d` into
`configuration.yml` at install time — the checkbox is on the login form
from day one, this is only about how long checking it actually lasts.
To change the duration later, use the menu instead of hand-editing the
file: re-run `sudo ./setup.sh authelia` against an existing install and
pick **"Change 'remember me' session duration"** (`_authelia_set_remember_me()`
in `services/authelia.sh`) — prompts for a new duration (`12h`, `7d`,
`1M`, `1y`, or `-1` to disable Remember Me entirely) and restarts.
Sessions persist through reboots regardless of duration (Redis stores
session state in a volume).
**`inactivity` must track `remember_me`, or a long remember_me is a lie.**
`inactivity` is a separate session field — how long a session can sit idle
before Authelia ends it — and it is NOT extended or bypassed by the
"Remember me" checkbox; the two are independent. Confirmed live: a user
set `remember_me: 1y` expecting "won't be asked to log in again for a
year," but the install default left `inactivity` at a much shorter value
(2h at the time), so ordinary daily gaps between visits (overnight, a
workday) ended the session on inactivity grounds well before remember_me
ever came into play — the 1y setting was doing nothing. Fixed at both ends
so this can't recur silently: `install_authelia()`'s own template now sets
`inactivity: 7d`, matching its `remember_me: 7d` default instead of a
shorter one, and `_authelia_set_remember_me()` now writes the SAME new
duration into both keys on every change, not just `remember_me` alone. If
you ever hand-edit `session:` instead of using the menu option, keep
`inactivity` and `remember_me` equal — a mismatch here is exactly the bug
above, not a valid intentional configuration. `expiration` (the cap for a
session that never checked "Remember me") is a legitimately different,
shorter-by-design setting and is untouched by any of this.
**The config key is `remember_me`, not `remember_me_duration`.** Authelia
renamed it in 4.38; this repo pins `4.39.20`. A stale `remember_me_duration`
key doesn't error, Authelia just silently ignores it — confirmed against
Authelia's own docs/changelog after this file's own example used the old
name for a while without anyone noticing, since nothing here actually
reads it back to verify the write took effect. If you ever do need to
touch this by hand instead of the menu option, the current schema is:
```yaml
session:
secret: 'your-existing-secret'
remember_me_duration: 1y # add or update this line
expiration: 1h
inactivity: 5m
inactivity: 1y
remember_me: 1y
cookies:
- domain: 'example.com'
authelia_url: 'https://auth.example.com'
```
After editing: `docker compose -f ~/docker/authelia/docker-compose.yml restart`
**This only covers Authelia's own session.** A native-OIDC app
(`gitea`/`mealie`/`actualbudget`) issues its own separate session/token
after logging in via Authelia, with its own independent expiry — a long
`remember_me` makes re-authenticating to Authelia itself instant/silent
whenever that app's own session expires and bounces you back through the
OIDC flow, but it doesn't stop that app's session from expiring on its
own schedule. If a native-OIDC app logs users out sooner than expected,
that app's own session-length setting (if it exposes one) is the other
thing to check, not this one.
## Non-Docker services
+4 -2
View File
@@ -186,11 +186,11 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`.
|-------|---------|
| `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network |
| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk` (own dedicated coturn for TURN/STUN — see `mattermost` below for the other coturn-owning service), `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine`, `vpn-data-mount` (mount existing SMB shares from a NetBird-connected home box — SSH trust bootstrap, then read-only discovery of shares already configured there; never writes to the home box's Samba config; repeatable, pick from any number of a home box's shares in one pass; optional per-share [gocryptfs decrypt layer](#client-side-encryption-for-vpn-data-mount) so the VPS only ever handles ciphertext) |
| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `garage-webui` (browser-based bucket/object browser for an existing `garage` install — folders/files view, the same kind of thing Backblaze's own web console gives you), `gatus`, `gitea` (self-hosted Git server — raw local clones plus optional two-way GitHub mirror sync, standalone from the `ai-stack` bundle's own Gitea container), `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) |
| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `garage-webui` (browser-based bucket/object browser for an existing `garage` install — folders/files view, the same kind of thing Backblaze's own web console gives you), `gatus`, `gitea` (self-hosted Git server — raw local clones plus optional two-way GitHub mirror sync, standalone from the `ai-stack` bundle's own Gitea container), `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `pressbooks` (self-hosted book platform — WordPress Multisite, drag-and-drop chapter editing, PDF export via PrinceXML/DocRaptor, Authelia-gated), `rustdesk`, `samba` (SMB/CIFS file sharing — shares, dedicated Samba users/passwords, LAN-scoped firewall by default; also offered as an optional nudge from `base`), `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) |
| `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` |
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
| `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) |
Run `./setup.sh --list` to see descriptions.
@@ -240,6 +240,7 @@ utilities
onlyoffice
paintplus
portainer
pressbooks
rustdesk
stirling-pdf
syncthing
@@ -282,6 +283,7 @@ extras
ssh-config
ssh-key-import
sync-cc
claude-cli
backup
backup
+92 -3
View File
@@ -416,6 +416,42 @@ _remove_caddy_site_block() {
' "$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
# block (if any), any UFW rule tagged with its name, and optionally its
# ~/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
}
# 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
# fixed host port) ────────────────────────────────────────────────────────────
# 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) ─────────────────
# 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"),
# 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() {
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
# 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.
CADDY_SERVICE_CONFIGURED=false
CADDY_SERVICE_MODE=""
CADDY_SERVICE_DOMAIN=""
# Derive the proxy upstream and a port number for display messages.
# Plain number → host.docker.internal:PORT (host-network or legacy
@@ -916,6 +987,15 @@ configure_caddy_for_service() {
if [ -z "$SERVICE_DOMAIN" ]; then
echo " ⚠ No domain provided, skipping Caddy configuration."; return 0
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
local _BLOCK_UPSTREAM="$_UPSTREAM"
@@ -931,6 +1011,15 @@ configure_caddy_for_service() {
_BLOCK_UPSTREAM="${_THIS_IP}:${_DISPLAY_PORT}"
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
_SITE_BLOCK="$(cat << CADDY_BLOCK
@@ -944,7 +1033,7 @@ ${SERVICE_DOMAIN} {
# after it would be dead code that never runs — full bypass regardless
# of what the auth server's own rules say.
${EXTRA_CONFIG}
reverse_proxy ${_BLOCK_UPSTREAM}
${_REVERSE_PROXY_LINE}
# Security headers
header {
+91
View File
@@ -180,6 +180,11 @@ CBLOCK
mkdir -p "$_dir"
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
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -198,6 +203,86 @@ fi
register_service actualbudget utilities "Open-source personal finance & budgeting (Actual Budget)" 5006
# Offers to add "Sign in with Authelia" (OpenID Connect) to Actual Budget's
# own login page — same additive pattern as services/gitea.sh's
# _gitea_offer_authelia_sso, entirely environment-variable driven like
# services/mealie.sh's equivalent. Confirmed against Actual Budget's own
# OIDC docs: ACTUAL_OPENID_DISCOVERY_URL, ACTUAL_OPENID_CLIENT_ID,
# ACTUAL_OPENID_CLIENT_SECRET, ACTUAL_OPENID_SERVER_HOSTNAME, appended
# into the .env file this installer already writes and reads via
# `env_file: .env`. Redirect path (/openid/callback) matches the preset
# already used by services/authelia.sh's own "Register an app" menu for
# this same app, so both stay consistent with each other.
#
# No stored BASE_URL to read back here (unlike Mealie) — Actual Budget's
# compose/.env never records the public URL, so this asks for the domain
# directly instead, same as services/gitea.sh's SSO offer does.
#
# Args: DIR
_actualbudget_offer_authelia_oidc() {
local DIR="$1"
[ -d "$DOCKER_DIR/authelia" ] || return 0
declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || 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 ""
local USE_SSO=""
prompt_yn " Add \"Sign in with Authelia\" (OpenID Connect) to Actual Budget's login page? (y/n):" "n" USE_SSO
[[ "$USE_SSO" =~ ^[Yy]$ ]] || return 0
local _default_domain=""
[ -n "${SITE_DOMAIN:-}" ] && [ "$SITE_DOMAIN" != "example.com" ] && _default_domain="budget.${SITE_DOMAIN}"
local AB_OIDC_DOMAIN=""
prompt_text " Domain Actual Budget is reachable at [${_default_domain:-required}]:" "$_default_domain" AB_OIDC_DOMAIN
if [ -z "$AB_OIDC_DOMAIN" ]; then
log_warning "No domain entered — skipping Authelia SSO for Actual Budget."
return 0
fi
local _2fa="" AUTH_POLICY="two_factor"
prompt_yn " Require two-factor for Actual Budget logins via Authelia too? (y/n):" "y" _2fa
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
if ! _authelia_provision_oidc_client "ActualBudget" "actualbudget" "$AUTH_POLICY" "y" "n" "" \
"https://${AB_OIDC_DOMAIN}/openid/callback"; then
log_warning "Couldn't register Actual Budget as an OIDC client in Authelia — skipping SSO setup."
return 0
fi
local _discovery_url="${OIDC_AUTHELIA_PORTAL_URL}/.well-known/openid-configuration"
cat >> "$DIR/.env" << ENV
# Written by services/actualbudget.sh's Authelia SSO step. The first OIDC
# login becomes the Actual Budget server owner if no owner is set yet —
# that's Actual Budget's own behavior, not something this script controls.
ACTUAL_OPENID_DISCOVERY_URL=$_discovery_url
ACTUAL_OPENID_CLIENT_ID=actualbudget
ACTUAL_OPENID_CLIENT_SECRET=$OIDC_CLIENT_SECRET_PLAIN
ACTUAL_OPENID_SERVER_HOSTNAME=https://${AB_OIDC_DOMAIN}
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 Actual Budget — 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 "actualbudget" "$AB_OIDC_DOMAIN"
}
install_actualbudget() {
require_docker || return 1
@@ -216,6 +301,7 @@ install_actualbudget() {
echo " - Create \$DOCKER_DIR/actualbudget(-<name>) with docker-compose.yml (data/)"
echo " - Auto-scan for a free host port if this is an additional instance"
echo " - Offer a Caddy reverse proxy and to start the container"
echo " - Offer \"Sign in with Authelia\" (OIDC) if Authelia is installed"
return 0
fi
@@ -258,6 +344,7 @@ install_actualbudget() {
( cd "$AB_DIR" && docker compose pull && docker compose up -d ) \
&& log_success "Actual Budget image refreshed" \
|| log_warning "Refresh failed — check: docker compose -f $AB_DIR/docker-compose.yml logs"
declare -F _actualbudget_offer_authelia_oidc >/dev/null 2>&1 && _actualbudget_offer_authelia_oidc "$AB_DIR"
return 0
;;
cancel)
@@ -306,6 +393,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << AB_COMPOSE
name: $CONTAINER
@@ -323,6 +411,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
AB_COMPOSE
backup_if_exists .env
cat > .env << AB_ENV
TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET
@@ -333,6 +422,8 @@ AB_ENV
configure_caddy_for_service "ActualBudget${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:5006" "budget${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
declare -F _actualbudget_offer_authelia_oidc >/dev/null 2>&1 && _actualbudget_offer_authelia_oidc "$AB_DIR"
write_readme "$AB_DIR" << MD
# Actual Budget${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
+8
View File
@@ -170,6 +170,11 @@ CBLOCK
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
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() {
local _len="${1:-32}"
@@ -368,6 +373,7 @@ install_ai-gpu() {
sed -i "s|America/New_York|$TZ_VAL|g" {} \;
fi
backup_if_exists "$IMAGE_GEN_DIR/.env"
cat > "$IMAGE_GEN_DIR/.env" << IMGENV
# InvokeAI — image generation
TZ=${TZ_VAL}
@@ -407,6 +413,7 @@ IMGENV
local WEBUI_SECRET
WEBUI_SECRET="$(generate_password 32)"
backup_if_exists "$LLM_DIR/.env"
cat > "$LLM_DIR/.env" << LLMENV
# Ollama + Open WebUI + SearXNG
TZ=${TZ_VAL}
@@ -441,6 +448,7 @@ LLMENV
fi
fi
backup_if_exists "$PORTAL_DIR/.env"
cat > "$PORTAL_DIR/.env" << PORTALENV
# AI Portal — GPU stack swap controller
TZ=${TZ_VAL}
+163
View File
@@ -10,6 +10,62 @@
less text (saves tokens), for both local and cloud models.
- 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)
One small GPU can't run local chat and local image-gen at once. Swap it:
```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`
## 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)
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
+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 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 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 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)"
@@ -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 " 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 " 0) Skip — stay fully local"
echo ""
log_info " Example: '1 2' wires Groq + DeepInfra. Leave blank to stay fully local."
log_info " Example: '1 2' wires Groq + DeepInfra."
echo ""
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
declare -a CLOUD_NAMES=() CLOUD_URLS=() CLOUD_KEYS=()
@@ -80,6 +83,7 @@ install_ai-stack() {
for _c in $CLOUD_CHOICES; do
_cname="" ; _curl=""
case "$_c" in
0) continue ;;
1) _cname="Groq"; _curl="https://api.groq.com/openai/v1" ;;
2) _cname="DeepInfra"; _curl="https://api.deepinfra.com/v1/openai" ;;
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"
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 ───────────────────────
if [ ${#CLOUD_NAMES[@]} -gt 0 ] && [ -f "$AS_DIR/docker-compose.yml" ]; then
# Prepend the local RAG connection so RAG keeps working, then the clouds.
+7
View File
@@ -174,6 +174,11 @@ CBLOCK
mkdir -p "$_dir"
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
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -240,6 +245,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << ABCOMPOSE
name: archivebox
@@ -262,6 +268,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
ABCOMPOSE
backup_if_exists .env
cat > .env << ABENV
CADDY_NET=$SITE_CADDY_NET
ABENV
+7
View File
@@ -188,6 +188,11 @@ CBLOCK
mkdir -p "$_dir"
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
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -278,6 +283,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << ARM_COMPOSE
name: arm
@@ -306,6 +312,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
ARM_COMPOSE
backup_if_exists .env
cat > .env << ARM_ENV
ARM_OUTPUT=$ARM_OUTPUT
CADDY_NET=$SITE_CADDY_NET
+467 -98
View File
@@ -255,6 +255,11 @@ CBLOCK
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
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() {
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,
# crowdsec) already probes for both directories, so both layouts stay fully
# 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() {
if [[ -f "$DOCKER_DIR/asterisk-digital-ocean/docker-compose.yml" ]]; then
ASTERISK_DIR="$DOCKER_DIR/asterisk-digital-ocean"
@@ -306,12 +326,30 @@ _asterisk_resolve_layout() {
ASTERISK_PROJECT="asterisk-do"
else
ASTERISK_DIR="$DOCKER_DIR/asterisk"
ASTERISK_CONTAINER="easy-asterisk"
ASTERISK_COTURN="easy-asterisk-coturn"
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
}
# 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 ─────────────────────────────────────────
# Sets IS_DO (true/false), DROPLET_ID and PUBLIC_IP.
#
@@ -667,9 +705,7 @@ case "$cmd" in
PJSIP_CONF="$HERE/config/asterisk/pjsip.conf"
if [ -f "$PJSIP_CONF" ]; then
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)"
[ -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}')"
NEW_EXT_IP="$(_asterisk_current_public_ip)"
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"
@@ -1155,6 +1191,48 @@ _asterisk_patch_voicemail_vendor_files() {
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 ─────────────────────────────────────────
# pstn-trunk.sh's dialplan leans on AST_CONFIG() for everything permission-
# related (pstn-permissions.conf tiers, pstn-trunk-killswitch.conf) — see
@@ -1230,6 +1308,111 @@ _asterisk_ensure_live_voicemail_include() {
docker exec "$CONTAINER_NAME" asterisk -rx "dialplan reload" &>/dev/null || true
}
# ── pjsip.conf: [global] keep_alive_interval ─────────────────────────────────
# Mitigation for WiFi/LAN SIP clients (e.g. Sipnetic on Android) dropping
# their TLS registration every few seconds on some hosts but not others —
# confirmed live on an IONOS VPS (never reproduced on a DigitalOcean droplet
# running the identical stack, and never over mobile data on either
# provider), with OPNsense's own firewall/IDS logs confirmed clean during a
# live disconnect and CrowdSec/packet-loss both ruled out first. Leading
# theory: an idle-connection timeout somewhere in IONOS's own network
# virtualization layer, below anything client-side tools can see. PJSIP's
# `keep_alive_interval` sends a lightweight double-CRLF over connection-oriented
# transports (TCP/TLS) on a timer, which is the standard fix for exactly this
# class of "idle SIP/TLS connection gets silently dropped" symptom.
#
# **This is a `type=global` option, not a `type=transport` option** — it does
# not exist on `[transport-tls]`/`[transport-tcp]` objects at all, on any
# Asterisk version. An earlier version of this patch inserted it into
# `[transport-tls]` (right after `protocol=tls`), which sorcery always
# 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() {
local EA_DIR="$1"
local ENTRYPOINT="$EA_DIR/docker/entrypoint.sh"
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 "$ENTRYPOINT" "$EASY1" "$EASY2"; do
[[ -f "$f" ]] || continue
# Undo the old, incorrect [transport-tls] placement if an earlier
# run of this function already patched it there.
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
fi
grep -q '^keep_alive_interval=' "$f" || sed -i '/^type=global$/a keep_alive_interval=15' "$f"
done
log_success "Vendor generator functions patched for SIP keepalive (pjsip.conf [global])."
}
# Live-file counterpart to the vendor-template patch above, same reasoning
# as _asterisk_ensure_live_voicemail_include (Easy Asterisk's entrypoint
# only regenerates pjsip.conf if it's missing, so a box with existing
# devices never picks up the vendor patch on a plain restart). Unlike
# extensions.conf/voicemail.conf, `pjsip reload` does not pick up changes to
# a transport object — PJSIP transports are bound at module load, not
# reloadable via sorcery like endpoints/AORs are — so this restarts the
# 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() {
local EA_DIR="$1" CONTAINER_NAME="$2"
local CONF_LIVE="$EA_DIR/config/asterisk/pjsip.conf"
[[ -f "$CONF_LIVE" ]] || return 0
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}"
return 0
fi
if ! grep -q '^keep_alive_interval=' "$CONF_LIVE"; then
sed -i '/^\[global\]$/,/^\[/{/^type=global$/a keep_alive_interval=15
}' "$CONF_LIVE"
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)..."
docker restart "$CONTAINER_NAME" &>/dev/null \
&& log_success "Restarted." \
|| log_warning "Restart failed — check: docker logs $CONTAINER_NAME"
}
_asterisk_remove_presence_timer() {
systemctl disable --now asterisk-presence-alert.timer 2>/dev/null || true
rm -f /etc/systemd/system/asterisk-presence-alert.timer /etc/systemd/system/asterisk-presence-alert.service
@@ -1410,6 +1593,7 @@ _asterisk_write_compose() {
"
[[ "$USE_EMBEDDED_COTURN" != true ]] && _COTURN_DEPENDS="" && _COTURN_SERVICE=""
backup_if_exists docker-compose.yml
cat > docker-compose.yml << EOF
name: PROJECT_NAME_PLACEHOLDER
@@ -1485,115 +1669,93 @@ EOF
_asterisk_configure_caddy_public() {
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
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
fi
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
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 ""
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
# 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"
[[ ! -d "$DOCKER_DIR/caddy" ]] && [[ -n "${CADDY_REMOTE_HOST:-}" ]] && _CADDY_MODE="remote"
# Asterisk runs with network_mode: host, so whatever proxies to it
# needs a way to reach the host, not "localhost" (which resolves
# to the proxying container's own netns). A local Caddy container
# reaches the host via host.docker.internal (wired up in
# services/caddy.sh's compose file); a remote Caddy machine needs
# this box's actual public IP instead.
local _PROXY_TARGET="host.docker.internal:${WEB_ADMIN_PORT_VAL}"
[[ "$_CADDY_MODE" == "remote" ]] && _PROXY_TARGET="${PUBLIC_IP}:${WEB_ADMIN_PORT_VAL}"
# Basic Auth handled entirely by Caddy — same generate/hash pattern as
# services/security-dashboard.sh's own independent Basic Auth layer.
local BASICAUTH_BLOCK=""
local _use_basicauth=""
prompt_yn " Add a Basic Auth login on this keep-alive page? (y/n):" "y" _use_basicauth
if [[ "$_use_basicauth" =~ ^[Yy]$ ]]; then
local BA_USER="" BA_PASS="" BA_HASH=""
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
_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} {
# Auth (if any) must come before reverse_proxy — forward_auth is the
# 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}
${BASICAUTH_BLOCK} respond "OK" 200
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
@@ -1632,10 +1794,10 @@ CADDY_BLOCK
# actually works here. Try it anyway, fall back to a
# restart — confirmed necessary on a real deployment.
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
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
log_warning "Reload/restart failed — check: docker logs caddy"
log_info "Manual fix: docker restart caddy"
@@ -1648,7 +1810,8 @@ CADDY_BLOCK
chown "$ACTUAL_USER:$ACTUAL_USER" "$_SNIPPET_DIR/asterisk.caddy" 2>/dev/null || true
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 "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
}
@@ -1742,6 +1905,199 @@ _asterisk_remind_non_do_firewall() {
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 ─────────────────────────────────────────────────────────
# One document with a droplet-only section appended in public-cloud mode, so
# the two deployment shapes can't document themselves differently by accident.
@@ -2094,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] 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] 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
fi
@@ -2141,6 +2501,8 @@ install_asterisk() {
_asterisk_write_voicemail_dialplan "$EA_DIR/config/asterisk/voicemail-dialplan.conf"
_asterisk_write_voicemail_conf "$EA_DIR/config/asterisk/voicemail.conf"
_asterisk_ensure_live_voicemail_include "$EA_DIR" "$CONTAINER"
_asterisk_patch_keepalive_vendor_files "$EA_DIR"
_asterisk_patch_cert_sync_cli "$EA_DIR"
ensure_docker_dir_ownership "$EA_DIR/config/asterisk"
chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" "$EA_DIR/config/asterisk/voicemail-dialplan.conf"
@@ -2148,6 +2510,7 @@ install_asterisk() {
if docker compose up -d --build --force-recreate; then
log_success "Update complete — vendor files and docker-compose.yml refreshed."
_asterisk_ensure_live_dangerously "$EA_DIR" "$CONTAINER"
_asterisk_ensure_live_keepalive "$EA_DIR" "$CONTAINER"
else
log_warning "docker compose up failed — check: docker compose -f $EA_DIR/docker-compose.yml logs"
fi
@@ -2170,8 +2533,11 @@ install_asterisk() {
local _EXISTING_DOMAIN _EXISTING_PORT
_EXISTING_DOMAIN="$(grep -E '^DOMAIN_NAME=' .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 ""
log_success "Existing .env, firewall rules, and Caddy/Authelia config were left untouched."
if [[ -n "$_EXISTING_DOMAIN" ]]; then
echo " Web admin: https://${_EXISTING_DOMAIN}/"
else
@@ -2237,6 +2603,7 @@ install_asterisk() {
_asterisk_write_voicemail_dialplan "$EA_DIR/config/asterisk/voicemail-dialplan.conf"
_asterisk_write_voicemail_conf "$EA_DIR/config/asterisk/voicemail.conf"
_asterisk_ensure_live_voicemail_include "$EA_DIR" "$CONTAINER"
_asterisk_patch_keepalive_vendor_files "$EA_DIR"
ensure_docker_dir_ownership "$EA_DIR/config/asterisk"
chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" "$EA_DIR/config/asterisk/voicemail-dialplan.conf"
@@ -2366,6 +2733,7 @@ install_asterisk() {
# WireGuard/Tailscale) on a subnet this box isn't directly attached to."
fi
backup_if_exists .env
cat > .env << ENV
# ── Domain ────────────────────────────────────────────────────
# ${_domain_comment}
@@ -2495,6 +2863,7 @@ ENV
if docker compose up -d --build; then
log_success "Easy Asterisk started"
_asterisk_ensure_live_dangerously "$EA_DIR" "$CONTAINER"
_asterisk_ensure_live_keepalive "$EA_DIR" "$CONTAINER"
else
log_warning "Start failed — check: docker compose logs"
fi
+63
View File
@@ -180,6 +180,11 @@ CBLOCK
mkdir -p "$_dir"
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
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -198,6 +203,59 @@ fi
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() {
require_docker || return 1
@@ -261,6 +319,7 @@ install_audiobookshelf() {
( cd "$ABS_DIR" && docker compose pull && docker compose up -d ) \
&& log_success "Audiobookshelf image refreshed" \
|| log_warning "Refresh failed — check: docker compose -f $ABS_DIR/docker-compose.yml logs"
_audiobookshelf_offer_authelia_oidc
return 0
;;
cancel)
@@ -325,6 +384,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << ABS_COMPOSE
name: $CONTAINER
@@ -346,6 +406,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
ABS_COMPOSE
backup_if_exists .env
cat > .env << ABS_ENV
AUDIOBOOKS_PATH=$AUDIOBOOKS_PATH
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}"
_audiobookshelf_offer_authelia_oidc
write_readme "$ABS_DIR" << MD
# Audiobookshelf${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
+1951 -140
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -21,6 +21,7 @@ install_base() {
echo "[DRY-RUN] Would offer to mount SMB data from a NetBird-connected home box (if NetBird is present)"
echo "[DRY-RUN] Would offer Caddy reverse proxy install (full repo only)"
echo "[DRY-RUN] Would offer CrowdSec intrusion prevention install (full repo only)"
echo "[DRY-RUN] Would offer Samba (SMB/CIFS) file sharing install (full repo only)"
echo "[DRY-RUN] Would offer to add SSH Host aliases to ~/.ssh/config"
return 0
fi
@@ -80,6 +81,14 @@ install_base() {
_base_setup_crowdsec
cd "$_BASE_PWD" 2>/dev/null || true
# ── Samba ────────────────────────────────────────────────────────────────
# Same nudge-not-mandatory shape as Caddy/CrowdSec above: fully optional,
# independently re-runnable later via `sudo ./setup.sh samba`. Defaults to
# n (unlike Caddy/CrowdSec) because it needs real input to be useful — a
# share path and at least one user — not just "yes, with sane defaults".
_base_setup_samba
cd "$_BASE_PWD" 2>/dev/null || true
# ── SSH Host aliases ─────────────────────────────────────────────────────
_base_setup_ssh_aliases
@@ -329,6 +338,22 @@ _base_setup_crowdsec() {
install_crowdsec
}
_base_setup_samba() {
if command -v smbd &>/dev/null; then
log_info "Samba already installed."
return 0
fi
# Only available when the full repo is sourced (setup.sh loads every
# services/*.sh up front) — a standalone copy of base.sh doesn't have
# install_samba, so skip silently rather than error.
declare -F install_samba &>/dev/null || return 0
local INSTALL_SAMBA=""
prompt_yn "Install Samba (SMB/CIFS) file sharing now — shares, users, passwords? (y/n):" "n" INSTALL_SAMBA
[[ "$INSTALL_SAMBA" =~ ^[Yy]$ ]] || return 0
install_samba
}
_base_setup_ssh_aliases() {
local ADD_ALIAS=""
prompt_yn "Add an SSH Host alias now ('ssh myserver' instead of 'ssh user@1.2.3.4')? (y/n):" "n" ADD_ALIAS
+119
View File
@@ -120,6 +120,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
local _companion; _companion="$(dirname "${BASH_SOURCE[0]}")/beszel.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
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"
}
# 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() {
require_docker || return 1
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
[[ "$FINISH_AGENT" =~ ^[Yy]$ ]] && _beszel_configure_agent "$DIR" "http://localhost:${_WP} (or its Caddy domain, once configured)"
fi
_beszel_offer_authelia_oidc "$DIR"
_beszel_offer_disable_password_auth "$DIR"
return 0
;;
cancel)
@@ -350,6 +463,7 @@ networks:
# profile recognizes. security_opt: apparmor:unconfined below is
# Beszel's own documented fix (beszel.dev/guide/systemd#apparmor-error)
# — confirmed live, this exact error on a real box.
backup_if_exists docker-compose.yml
cat > docker-compose.yml << BESZEL_COMPOSE
name: beszel
@@ -396,6 +510,7 @@ BESZEL_COMPOSE
# README). Not threaded through automatically here because Caddy setup
# (below) happens after this file is written, same ordering every
# other service in this repo uses for its own Caddy prompt.
backup_if_exists .env
cat > .env << BESZEL_ENV
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
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_offer_authelia_oidc "$DIR"
write_readme "$DIR" << 'BESZEL_README'
# 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
# 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).
backup_if_exists docker-compose.yml
cat > docker-compose.yml << AGENT_COMPOSE
name: beszel-agent
@@ -593,6 +711,7 @@ services:
- /sys/class/thermal:/sys/class/thermal:ro
AGENT_COMPOSE
backup_if_exists .env
cat > .env << AGENT_ENV
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
HUB_URL=$HUB_URL
+6
View File
@@ -192,6 +192,11 @@ CBLOCK
mkdir -p "$_dir"
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
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -279,6 +284,7 @@ install_caddy() {
cd "$CADDY_DIR" || return 1
backup_if_exists docker-compose.yml
cat > docker-compose.yml << 'CADDY_COMPOSE'
name: caddy
+7
View File
@@ -177,6 +177,11 @@ CBLOCK
mkdir -p "$_dir"
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
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -242,6 +247,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << CW_COMPOSE
name: calibre-web
@@ -264,6 +270,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
CW_COMPOSE
backup_if_exists .env
cat > .env << CW_ENV
CADDY_NET=$SITE_CADDY_NET
CW_ENV
+7
View File
@@ -177,6 +177,11 @@ CBLOCK
mkdir -p "$_dir"
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
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -243,6 +248,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << CD_COMPOSE
name: changedetection
@@ -272,6 +278,7 @@ ${_CADDY_NET_BLOCK} depends_on:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
CD_COMPOSE
backup_if_exists .env
cat > .env << CD_ENV
# Changedetection.io environment — edit before starting if needed
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"
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
# 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)}"
backup_if_exists docker-compose.yml
cat > docker-compose.yml << 'DDCLIENT_COMPOSE'
name: ddclient
@@ -229,6 +235,7 @@ services:
- ./config:/config
DDCLIENT_COMPOSE
backup_if_exists .env
cat > .env << DDCLIENT_ENV
TZ=$TZ_VAL
DDCLIENT_ENV
+7
View File
@@ -178,6 +178,11 @@ CBLOCK
mkdir -p "$_dir"
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
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -477,6 +482,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << DRUM_COMPOSE
name: drum-rhythm-game
@@ -492,6 +498,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
DRUM_COMPOSE
backup_if_exists .env
cat > .env << DRUM_ENV
CADDY_NET=${SITE_CADDY_NET}
DRUM_ENV
+7
View File
@@ -189,6 +189,11 @@ CBLOCK
mkdir -p "$_dir"
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
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -352,6 +357,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << EMBY_COMPOSE
name: $CONTAINER
@@ -377,6 +383,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
EMBY_COMPOSE
backup_if_exists .env
cat > .env << EMBY_ENV
MEDIA_PATH=$MEDIA_PATH
CADDY_NET=$SITE_CADDY_NET
+7
View File
@@ -177,6 +177,11 @@ CBLOCK
mkdir -p "$_dir"
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
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -315,6 +320,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << FB_COMPOSE
name: $CONTAINER
@@ -334,6 +340,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
FB_COMPOSE
backup_if_exists .env
cat > .env << FB_ENV
CADDY_NET=$SITE_CADDY_NET
FB_ENV
+7
View File
@@ -199,6 +199,11 @@ CBLOCK
mkdir -p "$_dir"
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
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -339,6 +344,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << FMD_COMPOSE
name: $CONTAINER
@@ -357,6 +363,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
FMD_COMPOSE
backup_if_exists .env
cat > .env << FMD_ENV
FMD_REGISTRATIONTOKEN=$FMD_TOKEN
CADDY_NET=$SITE_CADDY_NET
+7
View File
@@ -207,6 +207,11 @@ CBLOCK
mkdir -p "$_dir"
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
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -365,6 +370,7 @@ install_frigate-audio() {
# ── .env ──────────────────────────────────────────────────────────────────
log_info "Writing .env..."
backup_if_exists "$DIR/.env"
cat > "$DIR/.env" << ENVEOF
# Frigate audio stack — generated by setup.sh
# DO NOT commit this file — it contains credentials.
@@ -432,6 +438,7 @@ networks:
"
fi
backup_if_exists "$DIR/docker-compose.yml"
cat > "$DIR/docker-compose.yml" << 'COMPOSEEOF'
# Frigate NVR + Mosquitto MQTT + frigate-notify
# Generated by ubuntu-post-install setup.sh
+6
View File
@@ -174,6 +174,11 @@ CBLOCK
mkdir -p "$_dir"
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
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -234,6 +239,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << FN_COMPOSE
name: frigate-notify
+152 -8
View File
@@ -83,7 +83,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
}
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 _caddyfile="$_caddy_dir/Caddyfile"
local _display_port="${_upstream##*:}"
@@ -126,12 +126,23 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
_block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
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
_site_block="$(cat << CBLOCK
# $_name
${_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 {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
@@ -144,7 +155,6 @@ ${_domain} {
output file /var/log/caddy/${_domain}.log
format json
}
${_extra}
}
CBLOCK
)"
@@ -192,6 +202,11 @@ CBLOCK
mkdir -p "$_dir"
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
# 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 " or write a starter config.yml if none are added"
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
fi
@@ -612,6 +631,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << FRIGATE_COMPOSE
name: frigate
@@ -646,6 +666,106 @@ FRIGATE_COMPOSE
mkdir -p config
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
# 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.
@@ -654,12 +774,12 @@ FRIGATE_COMPOSE
if [ "${#CAM_NAME[@]}" -eq 0 ]; then
# 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
#
# ⚠️ 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
cameras:
@@ -696,7 +816,7 @@ FRIGATE_CONFIG
# RTSP credentials/IPs come from .env — Frigate substitutes {FRIGATE_VAR}
# 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
go2rtc:
@@ -721,9 +841,11 @@ snapshots:
FRIGATE_CONFIG
fi
backup_if_exists .env
cat > .env << FRIGATE_ENV
FRIGATE_MEDIA=$FRIGATE_MEDIA
CADDY_NET=$SITE_CADDY_NET
FRIGATE_PROXY_AUTH_SECRET=$FRIGATE_PROXY_SECRET
${ENV_CAM_VARS}
FRIGATE_ENV
chmod 600 .env
@@ -732,7 +854,29 @@ FRIGATE_ENV
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FRIGATE_MEDIA" 2>/dev/null || true
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
# Frigate NVR
@@ -746,7 +890,7 @@ security cameras. Detects people, cars, animals, and more.
- Recordings: \`$FRIGATE_MEDIA\`
- Config: \`config/config.yml\` — cameras configured during install (${#CAM_NAME[@]} total)
- Credentials: \`.env\` — RTSP user/pass/IP per camera as FRIGATE_* variables
${AUTH_README_SECTION}
## Manage
\`\`\`bash
cd $FRIGATE_DIR
+7
View File
@@ -92,6 +92,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mkdir -p "$_dir"
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
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -199,6 +204,7 @@ install_garage-webui() {
ensure_docker_dir_ownership "$DIR"
cd "$DIR" || return 1
backup_if_exists docker-compose.yml
cat > docker-compose.yml << COMPOSE
name: garage-webui
@@ -219,6 +225,7 @@ services:
- "${WEB_PORT}:3909"
COMPOSE
backup_if_exists .env
cat > .env << ENV
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"
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
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -277,6 +282,7 @@ api_bind_addr = "[::]:${ADMIN_PORT}"
admin_token = "${ADMIN_TOKEN}"
TOML
backup_if_exists docker-compose.yml
cat > docker-compose.yml << COMPOSE
name: garage
@@ -297,6 +303,7 @@ services:
- "${ADMIN_PORT}:${ADMIN_PORT}"
COMPOSE
backup_if_exists .env
cat > .env << ENV
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
+7
View File
@@ -187,6 +187,11 @@ CBLOCK
mkdir -p "$_dir"
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
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -405,6 +410,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << GATUS_COMPOSE
name: gatus
@@ -423,6 +429,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
GATUS_COMPOSE
backup_if_exists .env
cat > .env << GATUS_ENV
TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET
+461 -7
View File
@@ -104,6 +104,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mkdir -p "$_dir"
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
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
[[ "$_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
log_warning "Couldn't register Gitea as an OIDC client in Authelia — skipping SSO setup."
return 0
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..."
if docker exec -u git gitea gitea admin auth add-oauth \
--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"
}
# 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-
# workflow-compatible) with a local runner — mainly useful as a fallback so
# .gitea/workflows/*.yml can still run something like a GitHub Actions build
@@ -255,7 +346,11 @@ _gitea_offer_authelia_sso() {
_gitea_offer_actions_runner() {
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 ""
local USE_ACTIONS=""
@@ -358,6 +453,11 @@ _gitea_remove_sync_timer() {
# reconfigure of an existing one. Always asked (matches pstn-trunk.sh's
# international-calling step reasoning: a live-editable extra, not a
# structural setting tied exclusively to fresh installs).
#
# Sets _GITEA_SYNC_FLAG as an out-param (not `local` — read it after the
# call returns, same convention as CADDY_SERVICE_CONFIGURED) so the caller
# can decide whether the real-time webhook offer even makes sense for the
# direction just chosen.
_gitea_run_sync_direction_step() {
local DIR="$1"
@@ -368,12 +468,13 @@ _gitea_run_sync_direction_step() {
echo " 3) Both directions"
local _DIR_CHOICE=""
prompt_text " Choice [1]:" "1" _DIR_CHOICE
local FLAG="" DIR_DESC=""
local DIR_DESC=""
case "$_DIR_CHOICE" in
2) FLAG="--push-only"; DIR_DESC="Gitea -> GitHub only" ;;
3) FLAG=""; DIR_DESC="both directions" ;;
*) FLAG="--pull-only"; DIR_DESC="GitHub -> Gitea only" ;;
2) _GITEA_SYNC_FLAG="--push-only"; DIR_DESC="Gitea -> GitHub only" ;;
3) _GITEA_SYNC_FLAG=""; DIR_DESC="both directions" ;;
*) _GITEA_SYNC_FLAG="--pull-only"; DIR_DESC="GitHub -> Gitea only" ;;
esac
local FLAG="$_GITEA_SYNC_FLAG"
log_info "Sync direction: $DIR_DESC"
_gitea_remove_sync_timer
@@ -424,6 +525,250 @@ _gitea_run_sync_direction_step() {
esac
}
# ── Real-time sync: a GitHub webhook receiver, not just the timer above ────
# The timer above polls on a fixed schedule (default 6h) — fine for a slow
# backup cadence, but a genuine "GitHub -> Gitea in real time" ask needs
# GitHub to tell Gitea the moment something changes instead of Gitea finding
# out up to one interval late. GitHub's own webhook (repo Settings ->
# Webhooks) is the standard way to do that: it POSTs a JSON payload the
# instant someone pushes. This writes a tiny stdlib-only Python HTTP server
# to receive it — python3 is already a hard dependency of this directory's
# gitea-github-sync.sh itself (used there for JSON parsing), so this adds
# no new dependency — running under its own persistent systemd service,
# and wires it up to Caddy the same way every other web-facing piece of
# this install does.
#
# Deliberately NOT a Docker container: it just shells out to the existing
# gitea-github-sync.sh sitting right next to it in $DIR, the same way the
# timer's own systemd service does — no image to build/pull for what's
# fundamentally a few lines of stdlib HTTP handling.
_gitea_write_webhook_receiver() {
local DIR="$1"
cat > "$DIR/gitea-github-webhook.py" << 'PYEOF'
#!/usr/bin/env python3
"""Gitea <-> GitHub webhook receiver — triggers an immediate, single-repo
mirror sync (gitea-github-sync.sh --repo owner/name --pull-only) the moment
GitHub POSTs a push event, instead of waiting for the scheduled timer.
Written by services/gitea.sh — re-run 'sudo ./setup.sh gitea' (Update mode
is fine) to regenerate this file rather than hand-editing it; a hand edit
survives until the next Update-mode rerun overwrites it again.
WEBHOOK_SECRET is read from .env in this same directory at every request,
never taken from the environment/systemd unit — /etc/systemd/system/*.service
files are world-readable, and .env (chmod 600) is already where every other
token in this directory lives.
"""
import hashlib
import hmac
import http.server
import json
import os
import subprocess
import sys
SYNC_DIR = os.environ.get("GITEA_SYNC_DIR", os.path.dirname(os.path.abspath(__file__)))
ENV_PATH = os.path.join(SYNC_DIR, ".env")
PORT = int(os.environ.get("WEBHOOK_PORT", "3020"))
def _load_env_value(key):
try:
with open(ENV_PATH, "r") as f:
for line in f:
line = line.split("#", 1)[0].strip()
if not line.startswith(key + "="):
continue
return line[len(key) + 1:].strip().strip("'").strip('"')
except OSError:
pass
return ""
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
def _reply(self, code, body=b""):
self.send_response(code)
self.end_headers()
if body:
self.wfile.write(body)
def do_GET(self):
self._reply(200, b"gitea-github-webhook: listening\n")
def do_POST(self):
secret = _load_env_value("WEBHOOK_SECRET").encode()
if not secret:
self._reply(503, b"WEBHOOK_SECRET not configured")
return
length = int(self.headers.get("Content-Length", 0) or 0)
body = self.rfile.read(length) if length else b""
sig = self.headers.get("X-Hub-Signature-256", "")
expected = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
if not sig or not hmac.compare_digest(sig, expected):
self._reply(401, b"bad signature")
return
event = self.headers.get("X-GitHub-Event", "")
if event == "ping":
self._reply(200, b"pong")
return
if event != "push":
self._reply(204)
return
try:
payload = json.loads(body or b"{}")
full_name = payload["repository"]["full_name"]
except (json.JSONDecodeError, KeyError, TypeError):
self._reply(400, b"couldn't find repository.full_name in payload")
return
self._reply(202, b"sync queued\n")
sync_script = os.path.join(SYNC_DIR, "gitea-github-sync.sh")
sync_env = dict(os.environ, SYNC_ENV=ENV_PATH)
subprocess.Popen(
["bash", sync_script, "--repo", full_name, "--pull-only"],
cwd=SYNC_DIR,
env=sync_env,
)
if __name__ == "__main__":
server = http.server.ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
server.serve_forever()
PYEOF
chmod +x "$DIR/gitea-github-webhook.py"
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/gitea-github-webhook.py"
}
_gitea_write_webhook_service() {
local DIR="$1" RUN_USER="$2" RUN_HOME="$3" PORT="$4"
local _service="/etc/systemd/system/gitea-github-webhook.service"
cat > "$_service" << UNIT
[Unit]
Description=Gitea-GitHub Webhook Receiver (real-time mirror sync trigger)
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=${RUN_USER}
Environment=HOME=${RUN_HOME}
Environment=GITEA_SYNC_DIR=${DIR}
Environment=WEBHOOK_PORT=${PORT}
ExecStart=/usr/bin/python3 ${DIR}/gitea-github-webhook.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now gitea-github-webhook.service
}
_gitea_remove_webhook_service() {
systemctl disable --now gitea-github-webhook.service 2>/dev/null || true
rm -f /etc/systemd/system/gitea-github-webhook.service
systemctl daemon-reload 2>/dev/null || true
}
# Offers the webhook receiver above as an addition to (not a replacement
# for) the timer set up in _gitea_run_sync_direction_step — the timer keeps
# covering the Gitea -> GitHub direction (and acts as a safety net for any
# push GitHub's webhook delivery ever misses), the webhook just gets the
# GitHub -> Gitea direction down from "up to one interval late" to seconds.
# Always asked on every install/reconfigure, same "live-editable extra"
# pattern as the direction+autosync step itself — see that function's own
# comment. Skipped (and any existing webhook torn down) outright when the
# chosen direction is push-only, since GitHub has nothing to notify about
# in that direction.
_gitea_offer_realtime_webhook() {
local DIR="$1" SYNC_FLAG="$2"
if [[ "$SYNC_FLAG" == "--push-only" ]]; then
_gitea_remove_webhook_service
return 0
fi
echo ""
local USE_WEBHOOK=""
prompt_yn " Also add a GitHub webhook for near-instant sync (push on GitHub -> synced here in seconds, instead of waiting for the timer above)? (y/n):" "n" USE_WEBHOOK
if [[ ! "$USE_WEBHOOK" =~ ^[Yy]$ ]]; then
_gitea_remove_webhook_service
return 0
fi
# Reuse an existing secret/port across reruns — rotating either one
# silently breaks a webhook GitHub already has configured against the
# old value, the same reasoning services/asterisk.sh's TURN port-range
# persistence follows for a live coturn install.
local WEBHOOK_SECRET WEBHOOK_PORT
WEBHOOK_SECRET="$(grep '^WEBHOOK_SECRET=' "$DIR/.env" 2>/dev/null | cut -d= -f2- | tr -d "'\"")"
WEBHOOK_PORT="$(grep '^WEBHOOK_PORT=' "$DIR/.env" 2>/dev/null | cut -d= -f2- | tr -d "'\"")"
[[ -z "$WEBHOOK_SECRET" ]] && WEBHOOK_SECRET="$(generate_password 40)"
if [[ -z "$WEBHOOK_PORT" ]]; then
WEBHOOK_PORT=3020
find_free_port WEBHOOK_PORT "$WEBHOOK_PORT"
fi
if grep -q '^WEBHOOK_SECRET=' "$DIR/.env" 2>/dev/null; then
sed -i "s|^WEBHOOK_SECRET=.*|WEBHOOK_SECRET='${WEBHOOK_SECRET}'|" "$DIR/.env"
else
echo "WEBHOOK_SECRET='${WEBHOOK_SECRET}'" >> "$DIR/.env"
fi
if grep -q '^WEBHOOK_PORT=' "$DIR/.env" 2>/dev/null; then
sed -i "s|^WEBHOOK_PORT=.*|WEBHOOK_PORT='${WEBHOOK_PORT}'|" "$DIR/.env"
else
echo "WEBHOOK_PORT='${WEBHOOK_PORT}'" >> "$DIR/.env"
fi
chmod 600 "$DIR/.env"
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/.env"
_gitea_write_webhook_receiver "$DIR"
_gitea_write_webhook_service "$DIR" "$ACTUAL_USER" "$ACTUAL_HOME" "$WEBHOOK_PORT"
log_success "Webhook receiver running on port ${WEBHOOK_PORT} (systemctl status gitea-github-webhook)."
# Bare port -> host.docker.internal:PORT, same convention as every other
# host-process (non-container) upstream in this repo — see the
# configure_caddy_for_service usage note in CLAUDE.md.
configure_caddy_for_service "Gitea GitHub Webhook" "$WEBHOOK_PORT" "gitea-webhook"
if [[ "$CADDY_SERVICE_CONFIGURED" == true ]]; then
if command -v ufw &>/dev/null; then
if [[ "$CADDY_SERVICE_MODE" == "local" ]]; then
ufw delete allow "${WEBHOOK_PORT}/tcp" 2>/dev/null || true
ufw_allow_from_caddy_net "${WEBHOOK_PORT}"
else
ufw allow "${WEBHOOK_PORT}/tcp" comment "Gitea GitHub webhook" >/dev/null 2>&1 || true
ensure_ufw_enabled
fi
fi
echo ""
log_success "Now add the webhook on GitHub, for every repo you want instant sync from:"
log_info " Repo -> Settings -> Webhooks -> Add webhook"
log_info " Payload URL: https://${CADDY_SERVICE_DOMAIN}/"
log_info " Content type: application/json"
log_info " Secret: ${WEBHOOK_SECRET}"
log_info " Events: Just the push event"
log_info "The timer above still covers every other repo, and this one too, on its"
log_info "own schedule — the webhook is an addition, not a replacement for it."
else
log_warning "Webhook receiver is running (0.0.0.0:${WEBHOOK_PORT}) but nothing is exposing"
log_warning "it to the internet, so GitHub can't reach it yet — re-run this installer and"
log_warning "configure Caddy for it, or point your own reverse proxy at"
log_warning "127.0.0.1:${WEBHOOK_PORT} (or the container-reachable host IP) by hand."
log_info " Secret (for whenever you do expose it): ${WEBHOOK_SECRET}"
fi
}
install_gitea() {
log_info "Setting up self-hosted Gitea..."
@@ -434,13 +779,19 @@ install_gitea() {
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $DIR with docker-compose.yml (gitea/gitea:latest)"
echo "[DRY-RUN] Would scan for free host ports (web + SSH) to avoid collisions"
echo "[DRY-RUN] Would open the SSH clone port in UFW (web port too, or scoped to caddy_net"
echo "[DRY-RUN] if Caddy ends up fronting it locally)"
echo "[DRY-RUN] Would prompt for a Gitea admin username/password, then create that account"
echo "[DRY-RUN] and an API token once the container is ready (no manual web wizard)"
echo "[DRY-RUN] Would prompt for a GitHub token and copy in gitea-github-sync.sh"
echo "[DRY-RUN] Would ask sync direction (GitHub->Gitea / Gitea->GitHub / both) and whether"
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 a GitHub webhook receiver for near-instant GitHub->Gitea sync"
echo "[DRY-RUN] (systemd service + Caddy front door), unless direction is push-only"
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 write $DIR/README.md"
return 0
@@ -468,7 +819,9 @@ install_gitea() {
&& log_success "Gitea refreshed and restarted." \
|| log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"
_gitea_run_sync_direction_step "$DIR"
_gitea_offer_realtime_webhook "$DIR" "$_GITEA_SYNC_FLAG"
_gitea_offer_authelia_sso "$DIR"
_gitea_offer_reverse_proxy_auth "$DIR"
_gitea_offer_actions_runner "$DIR"
log_success "Existing .env (tokens) and web/SSH ports were left untouched."
return 0
@@ -492,6 +845,7 @@ install_gitea() {
[[ "$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}."
backup_if_exists docker-compose.yml
cat > docker-compose.yml << EOF
name: gitea
services:
@@ -606,6 +960,7 @@ EOF
cp -f "$SYNC_SRC" "$DIR/gitea-github-sync.sh"
chmod +x "$DIR/gitea-github-sync.sh"
backup_if_exists "$DIR/.env"
cat > "$DIR/.env" << ENV
# Written by services/gitea.sh — re-run that (update mode) to change any of this.
GITEA_URL='http://localhost:${WEB_PORT}'
@@ -630,6 +985,7 @@ ENV
fi
_gitea_run_sync_direction_step "$DIR"
_gitea_offer_realtime_webhook "$DIR" "$_GITEA_SYNC_FLAG"
# ── Caddy — no forward_auth gate here. Gitea has its own built-in login,
# unlike the no-auth-at-all apps elsewhere in this repo that need Caddy
@@ -638,7 +994,27 @@ ENV
# replacement requiring Caddy involvement. ─────────────────────────────
configure_caddy_for_service "Gitea" "host.docker.internal:${WEB_PORT}" "git"
# ── Firewall ─────────────────────────────────────────────────────────────
# SSH clone (SSH_PORT->22) is a different protocol than the web UI — Caddy
# can't front it no matter what CADDY_SERVICE_MODE came back as, so it
# always needs its own direct rule or `git clone ssh://...` hangs forever
# (a dropped SYN with UFW active, not a fast connection-refused).
if command -v ufw &>/dev/null; then
if [[ "$CADDY_SERVICE_CONFIGURED" == true && "$CADDY_SERVICE_MODE" == "local" ]]; then
ufw delete allow "${WEB_PORT}/tcp" 2>/dev/null || true
ufw_allow_from_caddy_net "${WEB_PORT}"
else
ufw allow "${WEB_PORT}/tcp" comment "Gitea web UI" >/dev/null 2>&1 || true
fi
ufw allow "${SSH_PORT}/tcp" comment "Gitea SSH clone" >/dev/null 2>&1 || true
ensure_ufw_enabled
log_success "UFW: opened SSH clone port ${SSH_PORT}/tcp"
else
log_warning "ufw not installed — if you use a firewall, open TCP ${SSH_PORT} for SSH clones."
fi
_gitea_offer_authelia_sso "$DIR"
_gitea_offer_reverse_proxy_auth "$DIR"
_gitea_offer_actions_runner "$DIR"
write_readme "$DIR" << MD
@@ -672,6 +1048,69 @@ Config (which repos, private/forks handling) lives at
\`~/.config/gitea-github-sync/config\` — edit directly, or re-run
\`bash gitea-github-sync.sh --init\` to redo it interactively.
## Real-time sync via GitHub webhook (optional)
The setup above only covers the GitHub -> Gitea direction; it doesn't apply
if you chose Gitea -> GitHub only (GitHub has nothing to notify about in
that direction). Adds a small Python HTTP server
(\`gitea-github-webhook.py\`, in this directory) run as its own systemd
service (\`gitea-github-webhook.service\`) that GitHub POSTs to the instant
someone pushes — it verifies the request's HMAC signature against
\`WEBHOOK_SECRET\` in \`.env\`, then runs \`gitea-github-sync.sh --repo
owner/name --pull-only\` for just that one repo. The scheduled timer above
still runs on its own interval regardless — the webhook is an addition
that gets the GitHub -> Gitea direction down to seconds, not a replacement
for it (and still catches anything a missed webhook delivery would have
picked up next interval anyway).
Not set up yet, or want to change the port/secret? Re-run
\`sudo ./setup.sh gitea\` (Update mode is fine) and answer yes to "Also add
a GitHub webhook...". That only stands up the *receiver* on this box — you
still add the actual webhook on GitHub's side afterward, using the payload
URL and secret the installer printed (also readable back from \`.env\` as
\`WEBHOOK_PORT\` / \`WEBHOOK_SECRET\` if you need them again).
**Option A — one repo at a time.** Fastest, but only covers repos you do
this for individually:
repo -> Settings -> Webhooks -> Add webhook
- Payload URL: the URL the installer printed
- Content type: \`application/json\`
- Secret: your \`WEBHOOK_SECRET\`
- Events: "Just the push event"
**Option B — every repo on your account, current AND future, from one
setup.** A plain repo webhook (Option A) is always per-repo, no way around
that — but a personal GitHub App installed with "All repositories" access
covers every repo automatically, including ones you create afterward. No
receiver/code change needed for this: an App's webhook uses the exact same
HMAC-secret mechanism as a repo webhook, so the same \`WEBHOOK_SECRET\`
works for both.
1. GitHub -> Settings -> Developer settings -> GitHub Apps -> New GitHub App
2. Webhook URL: same payload URL as Option A. Webhook secret: your
\`WEBHOOK_SECRET\`. (Homepage URL is a separate, purely cosmetic field —
point it at anything, e.g. your GitHub profile; GitHub never sends
anything there, unlike Webhook URL.)
3. Permissions -> Repository permissions -> Contents: Read-only (required
to unlock the Push event checkbox)
4. Subscribe to events: Push only
5. Where can this GitHub App be installed: "Only on this account"
6. Create it, then Install App -> choose "All repositories" -> Install
If you'd already added Option A webhooks on a few repos, they're now
redundant (not harmful, just two triggers per push) — remove them once
the App is confirmed working.
**Verify either option** — push to a repo, then watch it arrive:
\`\`\`bash
systemctl status gitea-github-webhook # is it running?
journalctl -u gitea-github-webhook -f # watch it receive + trigger syncs
\`\`\`
GitHub also shows delivery attempts and response codes: repo (or App) ->
Settings -> Webhooks -> the webhook -> Recent Deliveries.
## Sign in with Authelia (optional)
If Authelia is installed, re-run \`sudo ./setup.sh gitea\` (Update mode is
@@ -681,6 +1120,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
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
Re-run \`sudo ./setup.sh gitea\` (Update mode is fine) and answer yes to
+1
View File
@@ -274,6 +274,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << HOMEASSISTANT_COMPOSE
name: homeassistant
+54
View File
@@ -0,0 +1,54 @@
## "Please select an entity type" with an empty type dropdown
Some collections hit an upstream Homebox bug
([sysadminsmedia/homebox#1593](https://github.com/sysadminsmedia/homebox/issues/1593)):
the default `Location`/`Item` entity types never get seeded, so the Create
dialog's type dropdown comes up empty and every Location/Item creation fails
with "Please select an entity type."
### Option 1 — let the installer fix it
Re-run `sudo ./setup.sh homebox`, choose **update**, and say yes when asked
"Hit 'Please select an entity type' with an empty type list...?" You'll need
a Homebox API token (see step 2 below) — it's used once, right then, and
never written to `.env` or disk.
This only fixes the ONE collection that token's account belongs to. Homebox
has no documented way to switch a token between collections, so a
multi-collection setup needs this repeated once per collection — not
something the installer can do for you in one pass, and not really worth
the trouble if you've only hit this on one collection already.
### Option 2 — fix it by hand in the UI
1. Log into Homebox (register first if you haven't — the first account
becomes the admin).
2. Click the **collection selector** (shows your current collection's name,
near the top of the page) → **Collection options** (gear icon). This
opens Collection Settings.
3. Click the **Entity Types** tab (`/collection/entity-types`).
4. Click **Create**, add:
- Name: `Location`, with **Is Location** toggled **ON**
- Name: `Item`, with **Is Location** toggled **OFF**
5. Go back to the Create (+) dialog — "Select a type..." now lists both, so
you can create Locations and Items normally.
If you use more than one collection, repeat step 24 once per collection —
entity types are scoped per collection, not shared across your whole
Homebox instance.
### Option 3 — fix it via the API directly
Needs an API token from your profile menu (**Create API Token**):
```bash
curl -X POST "http://localhost:<port>/api/v1/entity-types" \
-H "Authorization: Bearer <your-token>" \
-H "Content-Type: application/json" \
-d '{"name":"Location","isLocation":true}'
curl -X POST "http://localhost:<port>/api/v1/entity-types" \
-H "Authorization: Bearer <your-token>" \
-H "Content-Type: application/json" \
-d '{"name":"Item","isLocation":false}'
```
+212
View File
@@ -177,6 +177,11 @@ CBLOCK
mkdir -p "$_dir"
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
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -196,6 +201,197 @@ fi
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"
}
# Some Homebox installs hit an upstream bug (sysadminsmedia/homebox#1593): a
# collection's default "Location"/"Item" entity types never get seeded, so
# the Create dialog's type dropdown comes up empty and every Location/Item
# creation fails with "Please select an entity type". There's no
# unauthenticated way to detect or fix this — entity types are scoped per
# collection (confirmed against Homebox's own swagger doc: GET/POST
# /v1/entity-types both require a bearer token) — so this is opt-in and
# asks for a token at the moment it runs, same trust model as Immich's
# _immich_offer_authelia_oidc(): pasted once, used once, never written to
# .env or disk.
#
# Args: DIR WEB_PORT
_homebox_offer_entity_type_fix() {
local DIR="$1" WEB_PORT="$2"
local LOCAL_URL="http://localhost:${WEB_PORT}"
echo ""
local _hit_bug=""
prompt_yn " Hit \"Please select an entity type\" with an empty type list when creating a Location/Item? (y/n):" "n" _hit_bug
[[ "$_hit_bug" =~ ^[Yy]$ ]] || return 0
echo ""
log_info "That's a known upstream Homebox bug (sysadminsmedia/homebox#1593) — this"
log_info "collection's default entity types were never seeded. Fixing it needs an"
log_info "API token from an account that's already registered:"
echo " 1. Log into Homebox in your browser (register first if you haven't)."
echo " 2. Open your profile menu -> Create API Token."
echo " 3. Paste it below — used once right now, never saved to disk."
echo ""
local HB_TOKEN=""
prompt_text " Homebox API token:" "" HB_TOKEN
if [ -z "$HB_TOKEN" ]; then
log_info "Skipped. Re-run 'sudo ./setup.sh homebox' (choose update) once you have a token."
return 0
fi
local VERIFY_CODE
VERIFY_CODE="$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $HB_TOKEN" "$LOCAL_URL/api/v1/users/self" 2>/dev/null)"
if [ "$VERIFY_CODE" != "200" ]; then
log_warning "Token didn't validate (HTTP $VERIFY_CODE) — skipping. Generate a fresh one and re-run."
return 0
fi
local EXISTING_TYPES
EXISTING_TYPES="$(curl -s -H "Authorization: Bearer $HB_TOKEN" "$LOCAL_URL/api/v1/entity-types" 2>/dev/null)"
if echo "$EXISTING_TYPES" | grep -q '"isLocation":[[:space:]]*true'; then
log_success "This collection already has a location-type entity type — nothing to fix."
return 0
fi
local _created=0 _code
_code="$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Authorization: Bearer $HB_TOKEN" \
-H "Content-Type: application/json" -d '{"name":"Location","isLocation":true}' \
"$LOCAL_URL/api/v1/entity-types" 2>/dev/null)"
[[ "$_code" == 20* ]] && _created=$((_created + 1))
_code="$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Authorization: Bearer $HB_TOKEN" \
-H "Content-Type: application/json" -d '{"name":"Item","isLocation":false}' \
"$LOCAL_URL/api/v1/entity-types" 2>/dev/null)"
[[ "$_code" == 20* ]] && _created=$((_created + 1))
if [ "$_created" -eq 2 ]; then
log_success "Created the missing 'Location' and 'Item' entity types — the Create dialog's type dropdown should be populated now."
else
log_warning "Something didn't go through cleanly — check the type dropdown in Homebox and retry if it's still empty."
fi
echo ""
log_info "This only fixes the ONE collection your token's account belongs to. Homebox"
log_info "has no documented way to switch a token between collections, so if you use"
log_info "more than one collection, log in as a member of each other one, generate a"
log_info "token there, and re-run this step for it too."
}
install_homebox() {
require_docker || return 1
log_info "Installing Homebox..."
@@ -256,6 +452,16 @@ install_homebox() {
( cd "$HB_DIR" && docker compose pull && docker compose up -d ) \
&& log_success "Homebox image refreshed" \
|| 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"
# WEB_PORT isn't persisted anywhere but the compose
# file itself — re-derive it here the same way
# services/immich.sh does for its own update-path
# offer, rather than assuming the pre-scan default.
local _EXISTING_PORT
_EXISTING_PORT="$(grep -oP '^\s+- "?\K[0-9]+(?=:7745)' "$HB_DIR/docker-compose.yml" 2>/dev/null | head -1)"
[ -n "$_EXISTING_PORT" ] && WEB_PORT="$_EXISTING_PORT"
_homebox_offer_entity_type_fix "$HB_DIR" "$WEB_PORT"
return 0
;;
cancel)
@@ -311,6 +517,7 @@ networks:
local HB_PEPPER
HB_PEPPER="$(generate_password 48)"
backup_if_exists docker-compose.yml
cat > docker-compose.yml << HB_COMPOSE
name: $CONTAINER
@@ -320,6 +527,7 @@ services:
container_name: $CONTAINER
hostname: $CONTAINER
restart: unless-stopped
env_file: .env
environment:
- HBOX_LOG_LEVEL=info
- HBOX_WEB_MAX_UPLOAD_SIZE=10
@@ -331,6 +539,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
HB_COMPOSE
backup_if_exists .env
cat > .env << HB_ENV
CADDY_NET=$SITE_CADDY_NET
@@ -346,6 +555,9 @@ HB_ENV
configure_caddy_for_service "Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:7745" "homebox${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
_homebox_offer_authelia_oidc "$HB_DIR" "$CONTAINER"
_homebox_offer_entity_type_fix "$HB_DIR" "$WEB_PORT"
write_readme "$HB_DIR" << MD
# Homebox${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
+148
View File
@@ -183,6 +183,11 @@ CBLOCK
mkdir -p "$_dir"
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
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
# 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() {
require_docker || return 1
@@ -279,6 +407,15 @@ install_immich() {
( cd "$IMMICH_DIR" && docker compose pull && docker compose up -d ) \
&& log_success "Immich image refreshed" \
|| 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
;;
cancel)
@@ -474,6 +611,7 @@ networks:
[ -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
name: $PROJECT
@@ -554,6 +692,7 @@ S3_SECRET_ACCESS_KEY=$S3_SECRET_ACCESS_KEY
fi
if [ "$IMMICH_STRATEGY" = "2" ]; then
backup_if_exists .env
cat > .env << IMMICH_ENV
# IMMICH CONFIGURATION — External Library Mode
#
@@ -581,6 +720,7 @@ TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET
IMMICH_ENV
else
backup_if_exists .env
cat > .env << IMMICH_ENV
# 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}"
# 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
# Immich${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
+8
View File
@@ -185,6 +185,11 @@ CBLOCK
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
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
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -314,6 +319,7 @@ networks:
fi
if [[ "$USE_GPU" =~ ^[Yy]$ ]]; then
backup_if_exists docker-compose.yml
cat > docker-compose.yml << IOPAINT_GPU
name: iopaint
@@ -346,6 +352,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
IOPAINT_GPU
else
backup_if_exists docker-compose.yml
cat > docker-compose.yml << IOPAINT_CPU
name: iopaint
@@ -373,6 +380,7 @@ IOPAINT_CPU
fi
# ── .env ─────────────────────────────────────────────────────────────────
backup_if_exists .env
cat > .env << IOPAINT_ENV
# 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"
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
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -347,6 +352,7 @@ networks:
'
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << JELLYFIN_COMPOSE
name: $CONTAINER
@@ -368,6 +374,7 @@ $HWACCEL_BLOCK
${_DISCOVERY_PORTS}${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
JELLYFIN_COMPOSE
backup_if_exists .env
cat > .env << JELLYFIN_ENV
MEDIA_PATH=$MEDIA_PATH
CADDY_NET=$SITE_CADDY_NET
+7
View File
@@ -177,6 +177,11 @@ CBLOCK
mkdir -p "$_dir"
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
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -314,6 +319,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << JOPLIN_COMPOSE
name: $CONTAINER
@@ -340,6 +346,7 @@ ${_CADDY_NET_BLOCK}
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
JOPLIN_COMPOSE
backup_if_exists .env
cat > .env << JOPLIN_ENV
# Joplin Server configuration
APP_PORT=22300
+6
View File
@@ -174,6 +174,11 @@ CBLOCK
mkdir -p "$_dir"
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
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -459,6 +464,7 @@ networks:
"
fi
backup_if_exists "$JS99ER_DIR/docker-compose.yml"
cat > "$JS99ER_DIR/docker-compose.yml" << COMPOSE
name: js99er
+7
View File
@@ -188,6 +188,11 @@ CBLOCK
mkdir -p "$_dir"
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
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -410,6 +415,7 @@ install_koha() {
fi
# ── docker-compose.yml ────────────────────────────────────────────────────
backup_if_exists docker-compose.yml
cat > docker-compose.yml << KOHA_COMPOSE
name: koha
@@ -481,6 +487,7 @@ ${_CADDY_NET_SECTION}
KOHA_COMPOSE
# ── config-main.env ───────────────────────────────────────────────────────
backup_if_exists config-main.env
cat > config-main.env << KOHA_ENV
# Koha ILS configuration — generated at install time
MYSQL_SERVER=koha-db
+7
View File
@@ -388,6 +388,7 @@ for a in data.get('assets', []):
mkdir -p "$DIR"
ensure_docker_dir_ownership "$DIR"
backup_if_exists "$DIR/docker-compose.yml"
cat > "$DIR/docker-compose.yml" << EOF
name: kyber-server
services:
@@ -411,6 +412,7 @@ EOF
# Write .env with restricted permissions
# Values are single-quoted so special characters ($, !, &, etc.) are safe.
# Exception: single quotes inside a value would still break — avoid them.
backup_if_exists "$DIR/.env"
cat > "$DIR/.env" << EOF
MAXIMA_CREDENTIALS='${EA_EMAIL}:${EA_PASSWORD}'
KYBER_TOKEN='${KYBER_TOKEN}'
@@ -558,6 +560,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mkdir -p "$_dir"
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_HOME=$(eval echo "~$ACTUAL_USER")
+7
View File
@@ -188,6 +188,11 @@ CBLOCK
mkdir -p "$_dir"
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
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -353,6 +358,7 @@ install_lyrion() {
_HTTP_PORT_INTERNAL="9000"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << LYRION_COMPOSE
name: $CONTAINER
@@ -375,6 +381,7 @@ ${_NETWORK_BLOCK}${_PORTS_BLOCK} environment:
LYRION_COMPOSE
backup_if_exists .env
cat > .env << LYRION_ENV
MUSIC_PATH=$MUSIC_PATH
CADDY_NET=$SITE_CADDY_NET
+6
View File
@@ -174,6 +174,11 @@ CBLOCK
mkdir -p "$_dir"
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
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -254,6 +259,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << MM_COMPOSE
name: mm-$MM_PORT
+7
View File
@@ -170,6 +170,11 @@ CBLOCK
mkdir -p "$_dir"
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
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -244,6 +249,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << MA_COMPOSE
name: mail-archiver
@@ -279,6 +285,7 @@ ${_CADDY_NET_BLOCK}
${_CADDY_NET_SECTION}
MA_COMPOSE
backup_if_exists .env
cat > .env << MA_ENV
# ── General ───────────────────────────────────────────────────────────────────
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
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() {
local _len="${1:-32}"
@@ -518,6 +523,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << EOF
name: ${PROJECT}
@@ -580,6 +586,7 @@ ${_CADDY_NET_BLOCK} healthcheck:
${_CADDY_NET_BLOCK}${_COTURN_SERVICE}${_CADDY_NET_SECTION}
EOF
backup_if_exists .env
cat > .env << EOF
TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET
+169
View File
@@ -180,6 +180,11 @@ CBLOCK
mkdir -p "$_dir"
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
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -198,6 +203,148 @@ fi
register_service mealie utilities "Recipe manager & meal planner (Mealie)" 9925
# Offers to add "Sign in with Authelia" (OpenID Connect) to Mealie's own
# login page — same additive pattern as services/gitea.sh's
# _gitea_offer_authelia_sso (local login keeps working unchanged), but
# Mealie's OIDC support is entirely environment-variable driven — no CLI
# equivalent to Gitea's `admin auth add-oauth` needed. Confirmed against
# Mealie's own OIDC docs: OIDC_AUTH_ENABLED, OIDC_CLIENT_ID,
# OIDC_CLIENT_SECRET, OIDC_CONFIGURATION_URL, OIDC_SIGNUP_ENABLED, appended
# straight into the .env file this installer already writes and reads via
# `env_file: .env` — no docker-compose.yml regeneration needed for that part.
#
# Reads BASE_URL back from the existing .env rather than taking it as an
# arg, so this works identically whether called right after a fresh
# install (where the URL was just computed) or from an Update rerun
# (where it wasn't recomputed this run, but is already on disk).
#
# Args: DIR CONTAINER
_mealie_offer_authelia_oidc() {
local DIR="$1" CONTAINER="$2"
[ -d "$DOCKER_DIR/authelia" ] || return 0
declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || 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
BASE_URL="$(grep '^BASE_URL=' "$DIR/.env" 2>/dev/null | cut -d= -f2-)"
if [ -z "$BASE_URL" ]; then
log_warning "Couldn't find BASE_URL in $DIR/.env — skipping Authelia SSO offer for Mealie."
return 0
fi
echo ""
local USE_SSO=""
prompt_yn " Add \"Sign in with Authelia\" (OpenID Connect) to Mealie's login page? (y/n):" "n" USE_SSO
[[ "$USE_SSO" =~ ^[Yy]$ ]] || return 0
local _2fa="" AUTH_POLICY="two_factor"
prompt_yn " Require two-factor for Mealie logins via Authelia too? (y/n):" "y" _2fa
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
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."
return 0
fi
local _discovery_url="${OIDC_AUTHELIA_PORTAL_URL}/.well-known/openid-configuration"
cat >> "$DIR/.env" << ENV
# Written by services/mealie.sh's Authelia SSO step — adds "Sign in with
# Authelia" alongside local login; local accounts keep working unchanged.
OIDC_AUTH_ENABLED=true
OIDC_SIGNUP_ENABLED=true
OIDC_CLIENT_ID=mealie
OIDC_CLIENT_SECRET=$OIDC_CLIENT_SECRET_PLAIN
OIDC_CONFIGURATION_URL=$_discovery_url
OIDC_PROVIDER_NAME=Authelia
ENV
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/.env" 2>/dev/null || true
# Mealie's OIDC redirect URI generation trusts X-Forwarded-* only from
# explicitly allowed IPs — without this, a Caddy-fronted instance
# generates an http:// redirect URI even when actually served over
# https://, which Authelia/any OIDC provider rejects as a scheme
# mismatch. Confirmed against Mealie's own reverse-proxy docs/issue
# tracker. Only needed (and only added) when Caddy is actually
# fronting this instance — BASE_URL itself tells us that (it's only
# ever https:// when a real domain + Caddy were configured).
if [[ "$BASE_URL" == https://* ]] && ! grep -q '^ entrypoint:' "$DIR/docker-compose.yml"; then
sed -i "/container_name: ${CONTAINER}\$/a\\ entrypoint: [\"uvicorn\", \"mealie.app:app\", \"--host\", \"0.0.0.0\", \"--port\", \"9000\", \"--forwarded-allow-ips=*\"]" "$DIR/docker-compose.yml"
fi
(cd "$DIR" && docker compose up -d) \
&& log_success "\"Sign in with Authelia\" added to Mealie — 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 "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() {
require_docker || return 1
@@ -217,6 +364,7 @@ install_mealie() {
echo " - Auto-scan for a free host port if this is an additional instance"
echo " - Default login: changeme@email.com / MyPassword (change immediately)"
echo " - Offer a Caddy reverse proxy and to start the container"
echo " - Offer \"Sign in with Authelia\" (OIDC) if Authelia is installed"
return 0
fi
@@ -259,6 +407,7 @@ install_mealie() {
( cd "$MEALIE_DIR" && docker compose pull && docker compose up -d ) \
&& log_success "Mealie image refreshed" \
|| log_warning "Refresh failed — check: docker compose -f $MEALIE_DIR/docker-compose.yml logs"
declare -F _mealie_offer_authelia_oidc >/dev/null 2>&1 && _mealie_offer_authelia_oidc "$MEALIE_DIR" "$CONTAINER"
return 0
;;
cancel)
@@ -315,6 +464,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << MEALIE_COMPOSE
name: $CONTAINER
@@ -339,6 +489,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
MEALIE_COMPOSE
backup_if_exists .env
cat > .env << MEALIE_ENV
# 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.
@@ -352,6 +503,24 @@ MEALIE_ENV
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"
write_readme "$MEALIE_DIR" << MD
# Mealie${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
+7
View File
@@ -188,6 +188,11 @@ CBLOCK
mkdir -p "$_dir"
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
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -323,6 +328,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << MC_COMPOSE
name: $CONTAINER
@@ -350,6 +356,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
MC_COMPOSE
backup_if_exists .env
cat > .env << MC_ENV
MC_HOSTNAME=$MC_HOSTNAME
MC_REVERSE_PROXY=false
+2
View File
@@ -1519,6 +1519,7 @@ PREGENINFOEOF
- ./config:/data/config"
fi
backup_if_exists "$MC_DIR/docker-compose.yml"
cat > "$MC_DIR/docker-compose.yml" << COMPOSEEOF
name: ${MC_NAME}
@@ -1902,6 +1903,7 @@ EXPOSE 80
CLIENTDOCKEREOF
# 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
name: ${CM_NAME}
+7
View File
@@ -177,6 +177,11 @@ CBLOCK
mkdir -p "$_dir"
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
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -242,6 +247,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << N8N_COMPOSE
name: n8n
@@ -266,6 +272,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
N8N_COMPOSE
backup_if_exists .env
cat > .env << N8N_ENV
# n8n environment — edit before starting if needed
N8N_HOST=n8n
+7
View File
@@ -70,6 +70,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mkdir -p "$_dir"
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
prompt_text() {
@@ -260,6 +265,7 @@ networks:
fi
# ── docker-compose.yml ──────────────────────────────────────────────────
backup_if_exists docker-compose.yml
cat > docker-compose.yml << NCCOMPOSE
name: nextcloud
services:
@@ -290,6 +296,7 @@ ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
NCCOMPOSE
# ── .env ────────────────────────────────────────────────────────────────
backup_if_exists .env
cat > .env << NCENV
TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET
+7
View File
@@ -184,6 +184,11 @@ CBLOCK
mkdir -p "$_dir"
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
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -309,6 +314,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << NTFY_COMPOSE
name: $CONTAINER
@@ -329,6 +335,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
NTFY_COMPOSE
backup_if_exists .env
cat > .env << NTFY_ENV
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
CADDY_NET=$SITE_CADDY_NET
+7
View File
@@ -70,6 +70,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mkdir -p "$_dir"
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
prompt_text() {
@@ -280,6 +285,7 @@ networks:
fi
# ── docker-compose.yml ──────────────────────────────────────────────────
backup_if_exists docker-compose.yml
cat > docker-compose.yml << OOCOMPOSE
name: onlyoffice
services:
@@ -299,6 +305,7 @@ ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
OOCOMPOSE
# ── .env ────────────────────────────────────────────────────────────────
backup_if_exists .env
cat > .env << OOENV
CADDY_NET=$SITE_CADDY_NET
# JWT authentication — keep JWT_SECRET private
+7
View File
@@ -210,6 +210,11 @@ CBLOCK
mkdir -p "$_dir"
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
# 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
# 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.
backup_if_exists docker-compose.yml
cat > docker-compose.yml << PIHOLE_COMPOSE
name: pihole
@@ -351,6 +357,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
PIHOLE_COMPOSE
backup_if_exists .env
cat > .env << PIHOLE_ENV
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
# Admin web UI password (System Console / login screen).
+6
View File
@@ -177,6 +177,11 @@ CBLOCK
mkdir -p "$_dir"
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
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -242,6 +247,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << PORTAINER_COMPOSE
name: portainer
+805
View File
@@ -0,0 +1,805 @@
#!/bin/bash
# services/pressbooks.sh — Self-hosted Pressbooks: write/import books, drag-and-drop
# image placement, export to PDF/EPUB for professional or personal printing.
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash pressbooks.sh
# (Docker must already be installed when run standalone)
#
# Pressbooks is a WordPress Multisite plugin/theme suite, not a normal
# WordPress plugin — its own docs are explicit that it "should be used with
# a fresh, multisite WordPress installation" and is "not for use on an
# existing blog." That means it can never be layered onto an existing
# services/wordpress.sh site: it gets its own dedicated WordPress core,
# database, and container here, converted to a Multisite network as part of
# this installer instead of a plain single-site install.
#
# Not following the multi-instance pattern documented in CLAUDE.md: that
# pattern exists for services that are inherently single-tenant per
# install. Pressbooks is the opposite — a single network already hosts any
# number of independent books (each its own site in the network, its own
# authors, its own theme), which is exactly the multi-tenancy the pattern
# gives other services. A second, fully separate Pressbooks *network* would
# only matter for something like two unrelated publishing organizations
# wanting entirely separate admin/user databases on one box — a much rarer
# need than "another book" — so it's left out of scope here.
#
# Chapters are written and images placed via WordPress's own block editor
# (Gutenberg) — dragging an image file into a chapter's content area drops
# an Image block at that position, and the block editor's own "Add Media"
# dialog also accepts drag-and-drop uploads. This is native WordPress
# behavior, not a Pressbooks feature, so it needs no extra plugin here.
#
# PDF export needs a rendering engine Pressbooks itself doesn't ship:
# - PrinceXML, installed on this container — free for non-commercial use
# (adds a small logo to page 1 of every PDF), full price for a
# commercial/watermark-free license. See install_pressbooks' Dockerfile
# generation below.
# - DocRaptor, PrinceXML as a paid SaaS API (DOCRAPTOR_API_KEY) — no local
# binary to maintain, but not free for real (non-watermarked) documents.
# - mPDF, Pressbooks' third documented option, is explicitly unmaintained
# upstream — not offered here.
# EPUB export needs no extra engine (Pressbooks generates it directly) and
# needs EPUBCheck's dependencies below. MOBI export was removed from
# Pressbooks entirely after Amazon discontinued KindleGen and stopped
# accepting MOBI on KDP (March 2025) — not offered here; see the generated
# README for the EPUB→MOBI-via-Calibre workaround for personal Kindle use.
# ── Standalone bootstrap ──────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
# shellcheck source=../lib/common.sh
source "$_COMMON"
else
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
require_docker() {
command -v docker &>/dev/null || {
log_error "Docker not found. Install it first:"
log_error " curl -fsSL https://get.docker.com | sudo sh"
return 1
}
docker compose version &>/dev/null || {
log_error "Docker Compose plugin missing:"
log_error " sudo apt-get install -y docker-compose-plugin"
return 1
}
}
ensure_docker_dir_ownership() {
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
generate_password() {
local _len="${1:-32}"
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_yn() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_reinstall_mode() {
local _var="$1" _r
if [[ "${UNATTENDED:-false}" == "true" ]]; then eval "$_var='cancel'"; return; fi
echo " Existing install detected. Choose:"
echo " u) Update — refresh plugin/theme/image, keep books and settings"
echo " f) Full reinstall — re-run every prompt from scratch"
echo " c) Cancel — leave everything as-is [default]"
read -r -p " Choice [u/f/c, Enter=cancel]: " _r
case "${_r,,}" in
u) eval "$_var='update'" ;;
f) eval "$_var='fresh'" ;;
*) eval "$_var='cancel'" ;;
esac
}
configure_caddy_for_service() {
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
local _caddy_dir="$DOCKER_DIR/caddy"
local _caddyfile="$_caddy_dir/Caddyfile"
local _display_port="${_upstream##*:}"
local _mode="none"
[[ -d "$_caddy_dir" ]] && _mode="local"
[[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote"
CADDY_SERVICE_CONFIGURED=false
CADDY_SERVICE_MODE=""
CADDY_SERVICE_DOMAIN=""
[[ "$_mode" == "none" ]] && {
log_info "Access $_name directly on port $_display_port."
return 0
}
echo ""
local _do_caddy=""
if [[ "$_mode" == "remote" ]]; then
log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})."
log_info "A snippet file will be saved to ~/docker/caddy-snippets/."
fi
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
[[ "${_do_caddy,,}" == "y" ]] || {
log_info "Skipping — access at: http://localhost:$_display_port"
return 0
}
local _default_domain=""
if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then
_default_domain="${_subdomain}.${SITE_DOMAIN}"
log_info "Default: $_default_domain"
fi
local _domain=""
read -r -p " Domain [${_default_domain:-required}]: " _domain
_domain="${_domain:-$_default_domain}"
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
local _block_upstream="$_upstream"
[[ "$_mode" == "remote" ]] && _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
local _site_block
_site_block="$(cat << CBLOCK
# $_name
${_domain} {
${_extra}
reverse_proxy ${_block_upstream}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output file /var/log/caddy/${_domain}.log
format json
}
}
CBLOCK
)"
if [[ "$_mode" == "local" ]]; then
if [[ -f "$_caddyfile" ]]; then
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
cp "$_caddyfile" "$_bk"
log_info "Backed up Caddyfile to $(basename "$_bk")"
else
touch "$_caddyfile"
fi
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
log_warning "$_domain already in Caddyfile"
local _ow=""
read -r -p " Overwrite? [y/N]: " _ow
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; CADDY_SERVICE_CONFIGURED=true; CADDY_SERVICE_MODE="local"; CADDY_SERVICE_DOMAIN="$_domain"; return 0; }
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
fi
CADDY_SERVICE_CONFIGURED=true
CADDY_SERVICE_MODE="local"
CADDY_SERVICE_DOMAIN="$_domain"
printf '%s\n' "$_site_block" >> "$_caddyfile"
log_success "Added $_domain to Caddyfile"
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
log_success "$_name accessible at: https://$_domain"
else
log_warning "Reload failed — check: docker logs caddy"
fi
else
CADDY_SERVICE_CONFIGURED=true
CADDY_SERVICE_MODE="remote"
CADDY_SERVICE_DOMAIN="$_domain"
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
local _snippet_file="$_snippet_dir/${_subdomain}.caddy"
mkdir -p "$_snippet_dir"
printf '%s\n' "$_site_block" > "$_snippet_file"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
log_success "Snippet saved: $_snippet_file"
fi
}
write_readme() {
local _dir="$1"; shift
mkdir -p "$_dir"
cat > "$_dir/README.md"
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
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
DRY_RUN="${DRY_RUN:-false}"
UNATTENDED="${UNATTENDED:-false}"
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
register_service() { :; }
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service pressbooks utilities "Self-hosted Pressbooks — write/import books with drag-and-drop images, export to PDF/EPUB (Authelia SSO gate)" 8095
# Fetches the newest release of a pressbooks/<repo> GitHub project as an
# installable zip URL, for wp-cli's own "plugin install <url>"/"theme install
# <url>" (which download and unpack it itself — nothing here needs to know
# how to unzip a WordPress plugin). Prefers an actual release asset (the
# packaged, ready-to-install zip these projects publish, vendor/ dependencies
# included) and falls back to the tagged source archive GitHub always
# generates automatically if no asset is found — that fallback can be
# missing composer's vendor/ directory, so it's logged with a warning
# rather than silently swapped in.
_pressbooks_latest_zip_url() {
local _repo="$1" _api _url _tag
_api="$(curl -fsSL "https://api.github.com/repos/pressbooks/${_repo}/releases/latest" 2>/dev/null)"
_url="$(printf '%s' "$_api" | grep -o '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*\.zip"' | head -1 | grep -o 'https://[^"]*')"
if [ -z "$_url" ]; then
_tag="$(printf '%s' "$_api" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | cut -d'"' -f4)"
if [ -n "$_tag" ]; then
_url="https://github.com/pressbooks/${_repo}/archive/refs/tags/${_tag}.zip"
log_warning "No packaged release asset found for ${_repo} — falling back to its" >&2
log_warning "tagged source archive, which can be missing composer's vendor/ directory." >&2
fi
fi
printf '%s' "$_url"
}
# A release zip's top-level directory sometimes carries a version suffix
# (especially the tagged-source-archive fallback above, e.g.
# "pressbooks-book-2.5.0/" instead of "pressbooks-book/") — WP-CLI's own
# "theme enable"/"plugin activate --network" need the directory to be named
# exactly the plugin/theme slug to find it at all. Renames it into place if
# a mismatch is found; a no-op if the zip already unpacked to the right name.
_pressbooks_normalize_slug() {
local _html_dir="$1" _kind="$2" _slug="$3"
docker run --rm -v "${_html_dir}:/var/www/html" alpine sh -c "
cd /var/www/html/wp-content/${_kind} 2>/dev/null || exit 0
[ -d '${_slug}' ] && exit 0
d=\$(ls -d ${_slug}-* 2>/dev/null | head -1)
[ -n \"\$d\" ] && mv \"\$d\" '${_slug}'
exit 0
" >/dev/null 2>&1
}
# Installs/refreshes the Pressbooks plugin and its three companion themes
# (McLuhan/pressbooks-book — the default book theme; Aldine — the default
# root theme; Publisher — the default theme for the network's own landing
# site) network-wide, and activates Publisher on the root site. Shared by
# the fresh-install path and the "update" rerun path (CLAUDE.md's
# non-destructive-update convention: this only ever touches plugin/theme
# code, never wp-config.php, .env, or any book's own content/DB rows).
_pressbooks_install_plugins_and_themes() {
local _dir="$1" _net="$2" _port="$3"
# "wp" is spelled out explicitly rather than relying on the wordpress:cli
# entrypoint's own "wp help $1 && set -- wp $@" auto-detection — that
# probe itself runs through wp-cli's bootstrap, so anything that breaks
# the bootstrap (a bad wp-config.php, a missing bind mount) makes the
# probe fail *silently* and falls through to exec-ing the raw
# subcommand as if it were a binary ("core: not found") instead of
# surfacing the real error.
_pb_wpcli() { docker run --rm --network "$_net" -v "${_dir}/html:/var/www/html" --env-file "${_dir}/.env" wordpress:cli wp "$@"; }
local _plugin_url _book_url _aldine_url _publisher_url
_plugin_url="$(_pressbooks_latest_zip_url pressbooks)"
_book_url="$(_pressbooks_latest_zip_url pressbooks-book)"
_aldine_url="$(_pressbooks_latest_zip_url pressbooks-aldine)"
_publisher_url="$(_pressbooks_latest_zip_url pressbooks-publisher)"
if [ -z "$_plugin_url" ]; then
log_error "Couldn't determine a Pressbooks download URL from GitHub (API unreachable or rate-limited)."
log_error "Install by hand instead: download a release zip from"
log_error " https://github.com/pressbooks/pressbooks/releases"
log_error "then, in Network Admin -> Plugins -> Add New -> Upload Plugin, upload it and Network Activate."
return 1
fi
log_info "Installing Pressbooks plugin..."
_pb_wpcli plugin install "$_plugin_url" --force || log_warning "Pressbooks plugin install reported an error — see docker compose logs."
_pressbooks_normalize_slug "${_dir}/html" plugins pressbooks
_pb_wpcli plugin activate pressbooks --network || log_warning "Network-activating Pressbooks failed — do it by hand in Network Admin -> Plugins."
log_info "Installing Pressbooks themes (McLuhan, Aldine, Publisher)..."
for _pair in "pressbooks-book:$_book_url" "pressbooks-aldine:$_aldine_url" "pressbooks-publisher:$_publisher_url"; do
local _slug="${_pair%%:*}" _url="${_pair#*:}"
[ -z "$_url" ] && { log_warning "Couldn't determine a download URL for theme $_slug — skipping."; continue; }
_pb_wpcli theme install "$_url" --force || log_warning "Theme $_slug install reported an error."
_pressbooks_normalize_slug "${_dir}/html" themes "$_slug"
_pb_wpcli theme enable "$_slug" || log_warning "Network-enabling $_slug failed — do it by hand in Network Admin -> Themes."
done
_pb_wpcli theme activate pressbooks-publisher --url="http://localhost:${_port}" \
|| log_warning "Couldn't set Publisher as the network's own landing-site theme — set it by hand in Appearance -> Themes."
}
install_pressbooks() {
require_docker || return 1
echo ""
echo "┌─────────────────────────────────────────────────────────────────┐"
echo "│ PRESSBOOKS │"
echo "│ Self-hosted book platform — write/import chapters with │"
echo "│ drag-and-drop images, export to PDF (print) and EPUB (ebook) │"
echo "└─────────────────────────────────────────────────────────────────┘"
echo ""
local DIR="$DOCKER_DIR/pressbooks"
local CONTAINER="pressbooks"
local DB_CONTAINER="pressbooks-db"
local WP_NET="pressbooks_net"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $DIR — a dedicated WordPress Multisite install (never an"
echo "[DRY-RUN] existing site — Pressbooks requires a fresh multisite network)"
echo "[DRY-RUN] Would build a custom image on wordpress:php8.3-apache: mod_rewrite +"
echo "[DRY-RUN] AllowOverride All (multisite needs working .htaccess rewrites),"
echo "[DRY-RUN] Ghostscript/ImageMagick/poppler-utils/libxml2-utils (cover generator +"
echo "[DRY-RUN] EPUB validation), and the ImageMagick PDF-coder policy fix Debian ships"
echo "[DRY-RUN] disabled by default"
echo "[DRY-RUN] Would prompt for a PDF export engine: PrinceXML (installed on this"
echo "[DRY-RUN] container, free for non-commercial use) and/or DocRaptor (paid SaaS API key)"
echo "[DRY-RUN] Would auto-scan for a free host port (8095 default) and dedicated MariaDB"
echo "[DRY-RUN] Would run wp-cli non-interactively: core install, convert to Multisite"
echo "[DRY-RUN] (subdirectory network), install+network-activate the Pressbooks plugin"
echo "[DRY-RUN] and its three themes"
echo "[DRY-RUN] Would offer a Caddy reverse proxy gated by Authelia SSO, and to start it"
return 0
fi
# ── Existing install? Offer update-in-place ──────────────────────────────
if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then
local MODE=""
prompt_reinstall_mode MODE
case "$MODE" in
update)
log_info "Refreshing Pressbooks' base image, cover-generator packages, and the"
log_info "Pressbooks plugin/themes only — books, domain, and credentials are left"
log_info "exactly as they are."
( cd "$DIR" && docker compose build --pull && docker compose up -d )
local _WP_PORT
_WP_PORT="$(grep '^WEB_PORT=' "$DIR/.env" | cut -d= -f2-)"
_pressbooks_install_plugins_and_themes "$DIR" "$WP_NET" "${_WP_PORT:-8095}"
log_success "Pressbooks refreshed"
return 0
;;
cancel)
log_info "Leaving the existing Pressbooks install as-is."
return 0
;;
fresh) ;;
esac
fi
# ── Network title + admin account ────────────────────────────────────────
local PB_TITLE="" PB_ADMIN_USER="" PB_ADMIN_EMAIL=""
prompt_text "Book network title (shown on the landing site):" "My Book Library" PB_TITLE
prompt_text "Admin username:" "admin" PB_ADMIN_USER
prompt_text "Admin email:" "" PB_ADMIN_EMAIL
local PB_ADMIN_PASS=""
[ -f "$DIR/.env" ] && PB_ADMIN_PASS="$(grep '^WP_ADMIN_PASSWORD=' "$DIR/.env" | cut -d= -f2-)"
[ -n "$PB_ADMIN_PASS" ] || PB_ADMIN_PASS="$(generate_password 16)"
# ── PDF export engine ─────────────────────────────────────────────────────
echo ""
echo " PDF export needs a rendering engine Pressbooks itself doesn't ship."
local INSTALL_PRINCE="" PRINCE_LICENSE_PATH="" USE_DOCRAPTOR="" DOCRAPTOR_KEY=""
prompt_yn "Install PrinceXML for PDF export? Free for personal use, adds a small logo unless licensed (y/n):" "y" INSTALL_PRINCE
if [[ "$INSTALL_PRINCE" =~ ^[Yy]$ ]]; then
prompt_text " Already have a paid PrinceXML license file (removes the logo)? Path, or blank to skip:" "" PRINCE_LICENSE_PATH
if [ -n "$PRINCE_LICENSE_PATH" ] && [ ! -f "$PRINCE_LICENSE_PATH" ]; then
log_warning " $PRINCE_LICENSE_PATH not found — continuing with the free non-commercial version."
PRINCE_LICENSE_PATH=""
fi
fi
prompt_yn "Also configure DocRaptor (SaaS alternative — needs your own API key, paid past a small free quota)? (y/n):" "n" USE_DOCRAPTOR
if [[ "$USE_DOCRAPTOR" =~ ^[Yy]$ ]]; then
prompt_text " DocRaptor API key (from https://docraptor.com/documentation/api):" "" DOCRAPTOR_KEY
fi
if [[ ! "$INSTALL_PRINCE" =~ ^[Yy]$ ]] && [ -z "$DOCRAPTOR_KEY" ]; then
log_warning "No PDF engine configured — Pressbooks' PDF export will fail until PrinceXML"
log_warning "or DocRaptor is set up (re-run this installer to add one later)."
fi
# ── Free host port ────────────────────────────────────────────────────────
local WEB_PORT=8095
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$DIR/html" "$DIR/db" "$DIR/uploads-ini.d"
ensure_docker_dir_ownership "$DIR"
cd "$DIR" || return 1
local TZ_VAL="${SITE_TZ:-UTC}"
# Book covers, full-book PDF/EPUB exports, and large chapter-image
# imports all run well past stock PHP limits — sized generously up front
# rather than waiting for a first export to hit a wall.
cat > uploads-ini.d/uploads.ini << 'PHPINI'
file_uploads = On
memory_limit = 512M
upload_max_filesize = 128M
post_max_size = 128M
max_execution_time = 600
max_input_time = 600
PHPINI
# WordPress core's own is_ssl() only looks at $_SERVER['HTTPS'], never
# X-Forwarded-Proto — behind Caddy (which terminates TLS and proxies
# plain HTTP to this container) that reads as "never HTTPS," sending
# wp-admin into a login/redirect loop the moment Caddy is wired up.
#
# This lives in a must-use plugin (wp-content/mu-plugins/, autoloaded by
# WordPress on every request, no activation needed) rather than in
# wp-config.php via WORDPRESS_CONFIG_EXTRA — two real, confirmed-live
# problems with the wp-config.php route, in order of discovery:
# 1. Compose interpolates $VAR-looking tokens found INSIDE .env file
# values too, not just inside docker-compose.yml — a raw $_SERVER
# sitting in .env got silently blanked to a bare "_SERVER" before
# the container ever saw it.
# 2. Routing it through a bind-mounted file and a wp-config.php
# `require` line (this repo's first fix for #1) traded that bug for
# a worse one: wp-cli's Runner does its own restricted, line-level
# parsing of wp-config.php to pull out bootstrap constants without
# a full WordPress load, and it can't handle anything past a plain
# define(...) statement — an if(){ require ...; } line made *every*
# wp-cli command in this script fail with a cryptic
# "PHP Parse error ... eval()'d code ... unexpected end of file".
# mu-plugins load through WordPress's normal plugin bootstrap, not
# wp-cli's special wp-config.php pre-parser, so this sidesteps both
# issues entirely — nothing here ever touches wp-config.php or .env.
mkdir -p html/wp-content/mu-plugins
cat > html/wp-content/mu-plugins/pressbooks-extra-config.php << 'PHPEXTRA'
<?php
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
$_SERVER['HTTPS'] = 'on';
}
PHPEXTRA
[[ "$INSTALL_PRINCE" =~ ^[Yy]$ ]] && echo "define('PB_PRINCE_COMMAND', '/usr/local/bin/prince');" >> html/wp-content/mu-plugins/pressbooks-extra-config.php
[ -n "$DOCRAPTOR_KEY" ] && echo "define('DOCRAPTOR_API_KEY', '$DOCRAPTOR_KEY');" >> html/wp-content/mu-plugins/pressbooks-extra-config.php
# Prince license file, if provided, is bind-mounted rather than baked
# into the image — keeps a personal/purchased license out of the image
# layer, and survives an image rebuild on the "update" path untouched.
local PRINCE_LICENSE_VOLUME=""
if [ -n "$PRINCE_LICENSE_PATH" ]; then
cp "$PRINCE_LICENSE_PATH" "$DIR/prince-license.dat"
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/prince-license.dat"
chmod 600 "$DIR/prince-license.dat"
PRINCE_LICENSE_VOLUME=" - ./prince-license.dat:/usr/local/lib/prince/license/license.dat:ro
"
fi
# ── Dockerfile ────────────────────────────────────────────────────────────
local _PRINCE_DOCKERFILE_BLOCK=""
if [[ "$INSTALL_PRINCE" =~ ^[Yy]$ ]]; then
_PRINCE_DOCKERFILE_BLOCK='
# PrinceXML — the PDF rendering engine Pressbooks shells out to for PDF
# export. Free for non-commercial use (small logo on page 1 of every PDF; a
# purchased license.dat, bind-mounted by docker-compose.yml, removes it).
# Uses the "linux-generic" tarball rather than a distro-pinned .deb/.rpm so
# this keeps working if wordpress:php8.3-apache'"'"'s underlying Debian release
# moves on, and resolves the current major version + exact filename at
# build time instead of hardcoding one that will eventually go stale.
RUN set -eux; \
ARCH="$(uname -m)"; \
MAJOR="$(curl -fsSL https://www.princexml.com/download/ | grep -oE "/download/[0-9]+/" | grep -oE "[0-9]+" | sort -n | tail -1)"; \
TARBALL_PATH="$(curl -fsSL "https://www.princexml.com/download/${MAJOR}/" | grep -oE "/download/prince-[0-9.]+-linux-generic-${ARCH}\.tar\.gz" | head -1)"; \
curl -fsSL "https://www.princexml.com${TARBALL_PATH}" -o /tmp/prince.tar.gz; \
mkdir -p /tmp/prince && tar -xzf /tmp/prince.tar.gz -C /tmp/prince --strip-components=1; \
printf "\n" | /tmp/prince/install.sh; \
rm -rf /tmp/prince /tmp/prince.tar.gz
'
fi
backup_if_exists Dockerfile
cat > Dockerfile << DOCKERFILE
FROM wordpress:php8.3-apache
# Multisite's subdirectory rewrite rules live in .htaccess — the base
# php-apache image ships mod_rewrite disabled and AllowOverride None, so
# .htaccess is silently ignored (pretty URLs 404, book pages don't route)
# without this.
RUN a2enmod rewrite \\
&& sed -i 's/AllowOverride None/AllowOverride All/' /etc/apache2/apache2.conf
# Pressbooks' cover generator shells out to Ghostscript/ImageMagick and
# poppler-utils (pdftoppm/pdfinfo) to rasterize book covers; libxml2-utils
# (xmllint) backs EPUB/HTMLBook validation. curl/ca-certificates are needed
# by the PrinceXML install step below, when enabled.
RUN apt-get update \\
&& apt-get install -y --no-install-recommends \\
ghostscript imagemagick poppler-utils libxml2-utils curl ca-certificates \\
&& rm -rf /var/lib/apt/lists/*
# Debian's ImageMagick ships a security policy (a CVE-2016-3714 mitigation)
# that blocks the PDF/PS/EPS coders by default. Without this, ImageMagick
# refuses to rasterize the PDF Ghostscript hands it for a cover thumbnail —
# fails with "not authorized \`PDF'" rather than producing an image.
RUN for f in /etc/ImageMagick-6/policy.xml /etc/ImageMagick-7/policy.xml; do \\
[ -f "\$f" ] && sed -i -E 's/rights="none" pattern="(PDF|PS|EPS)"/rights="read|write" pattern="\\1"/' "\$f"; \\
done; true
${_PRINCE_DOCKERFILE_BLOCK}
DOCKERFILE
# ── Caddy network wiring ──────────────────────────────────────────────────
local _CADDY_MODE="${CADDY_MODE:-none}"
[ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local"
[ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote"
local _CADDY_NET_LINE="" _CADDY_NET_SECTION=""
if [ "$_CADDY_MODE" = "local" ]; then
_CADDY_NET_LINE=" - caddy_net
"
_CADDY_NET_SECTION="
caddy_net:
external: true
name: ${SITE_CADDY_NET:-caddy_net}
"
fi
# ── docker-compose.yml ────────────────────────────────────────────────────
backup_if_exists docker-compose.yml
cat > docker-compose.yml << PBCOMPOSE
name: pressbooks
services:
pressbooks:
build: .
container_name: $CONTAINER
hostname: $CONTAINER
restart: unless-stopped
env_file: .env
depends_on:
- db
volumes:
- ./html:/var/www/html
- ./uploads-ini.d/uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro
${PRINCE_LICENSE_VOLUME} ports:
- "${WEB_PORT}:80"
networks:
- default
${_CADDY_NET_LINE}
db:
image: mariadb:11
container_name: $DB_CONTAINER
hostname: $DB_CONTAINER
restart: unless-stopped
env_file: .env
volumes:
- ./db:/var/lib/mysql
networks:
- default
networks:
default:
name: $WP_NET
${_CADDY_NET_SECTION}
PBCOMPOSE
# ── .env ──────────────────────────────────────────────────────────────────
local WP_DB_PASS="" WP_DB_ROOT_PASS=""
[ -f ".env" ] && WP_DB_PASS="$(grep '^WORDPRESS_DB_PASSWORD=' .env | cut -d= -f2-)"
[ -f ".env" ] && WP_DB_ROOT_PASS="$(grep '^MYSQL_ROOT_PASSWORD=' .env | cut -d= -f2-)"
[ -n "$WP_DB_PASS" ] || WP_DB_PASS="$(generate_password 24)"
[ -n "$WP_DB_ROOT_PASS" ] || WP_DB_ROOT_PASS="$(generate_password 32)"
backup_if_exists .env
cat > .env << PBENV
TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET
WEB_PORT=$WEB_PORT
# Dedicated MariaDB for this network alone.
MYSQL_ROOT_PASSWORD=$WP_DB_ROOT_PASS
MYSQL_DATABASE=pressbooks
MYSQL_USER=pressbooks
MYSQL_PASSWORD=$WP_DB_PASS
WORDPRESS_DB_HOST=$DB_CONTAINER
WORDPRESS_DB_NAME=pressbooks
WORDPRESS_DB_USER=pressbooks
WORDPRESS_DB_PASSWORD=$WP_DB_PASS
# Only consulted by wp-cli during initial setup below, not read by the
# wordpress:apache image itself.
WP_SITE_TITLE=$PB_TITLE
WP_ADMIN_USER=$PB_ADMIN_USER
WP_ADMIN_PASSWORD=$PB_ADMIN_PASS
WP_ADMIN_EMAIL=$PB_ADMIN_EMAIL
PBENV
chmod 600 .env
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR"
log_success "Pressbooks configured at $DIR (port $WEB_PORT)"
log_info "Building image (first build downloads PrinceXML and cover-generator packages — can take a few minutes)..."
if ! docker compose build; then
log_error "Image build failed — check the output above."
return 1
fi
if ! docker compose up -d; then
log_error "Failed to start — check: docker compose logs"
return 1
fi
log_info "Waiting for WordPress to come up..."
local _tries=0
until docker exec "$CONTAINER" curl -fs -o /dev/null http://localhost/ 2>/dev/null || [ "$_tries" -ge 30 ]; do
sleep 1; _tries=$((_tries + 1))
done
# "wp" spelled out explicitly — see _pb_wpcli's comment above for why.
_wpcli() { docker run --rm --network "$WP_NET" -v "$DIR/html:/var/www/html" --env-file "$DIR/.env" wordpress:cli wp "$@"; }
log_info "Running wp-cli core install..."
if ! _wpcli core install \
--url="http://localhost:${WEB_PORT}" \
--title="$PB_TITLE" \
--admin_user="$PB_ADMIN_USER" \
--admin_password="$PB_ADMIN_PASS" \
--admin_email="$PB_ADMIN_EMAIL" \
--skip-email; then
log_error "wp-cli core install failed — WordPress may not have been ready yet. Retry manually:"
log_error " docker run --rm --network $WP_NET -v $DIR/html:/var/www/html \\"
log_error " --env-file $DIR/.env wordpress:cli wp core install ..."
return 1
fi
# Multisite refuses to activate with the "Plain" (query-string) permalink
# structure — pretty permalinks are a hard prerequisite, not optional.
log_info "Setting pretty permalinks and converting to a Multisite network..."
_wpcli rewrite structure '/%postname%/' --hard
_wpcli core multisite-convert --title="$PB_TITLE"
# multisite-convert doesn't rewrite .htaccess itself on Apache — without
# this, every site but the root 404s.
_wpcli rewrite flush --hard
_pressbooks_install_plugins_and_themes "$DIR" "$WP_NET" "$WEB_PORT"
# ── Authelia SSO gate ─────────────────────────────────────────────────────
# Pressbooks/WordPress has its own login screen, but no native
# OIDC/reverse-proxy-auth support the way gitea/mealie do (a third-party
# plugin could add one, the same "bigger lift" caveat CLAUDE.md notes for
# Jellyfin/Home Assistant) — so this is the same forward_auth gate used
# for services with no built-in auth at all: Authelia guards the front
# door, WordPress's own login is still a second gate behind it.
local EXTRA_BLOCK=""
if [ -d "$DOCKER_DIR/authelia" ]; then
local USE_AUTHELIA=""
prompt_yn "Protect Pressbooks with Authelia SSO? (y/n):" "y" USE_AUTHELIA
[[ "$USE_AUTHELIA" =~ ^[Yy]$ ]] && EXTRA_BLOCK=" import authelia"
fi
configure_caddy_for_service "Pressbooks" "${CONTAINER}:80" "books" "$EXTRA_BLOCK"
# Reconcile the domain WordPress/Multisite think they're on: core install
# ran against http://localhost:$WEB_PORT since the final domain isn't
# known until the Caddy prompt above. Two passes — the full scheme+host
# string first (catches siteurl/home, stored with "http://"), then the
# bare host (catches wp_site.domain/wp_blogs.domain, stored without a
# scheme) — doing it in the other order would leave siteurl/home on
# "http://" instead of "https://" once Caddy is terminating TLS.
if [ "$CADDY_SERVICE_CONFIGURED" = true ] && [ -n "$CADDY_SERVICE_DOMAIN" ]; then
_wpcli search-replace "http://localhost:${WEB_PORT}" "https://${CADDY_SERVICE_DOMAIN}" --network --all-tables --report-changed-only
_wpcli search-replace "localhost:${WEB_PORT}" "$CADDY_SERVICE_DOMAIN" --network --all-tables --report-changed-only
log_success "Updated the network's URLs to https://$CADDY_SERVICE_DOMAIN"
fi
declare -F _authelia_scope_access >/dev/null 2>&1 && [ "$CADDY_SERVICE_CONFIGURED" = true ] \
&& _authelia_scope_access "pressbooks" "$CADDY_SERVICE_DOMAIN"
local PB_ACCESS_URL="http://localhost:${WEB_PORT}"
[ "$CADDY_SERVICE_CONFIGURED" = true ] && PB_ACCESS_URL="https://$CADDY_SERVICE_DOMAIN"
write_readme "$DIR" << MD
# Pressbooks
Self-hosted book platform on a dedicated WordPress Multisite network (its
own container/database — never shares an install with \`services/wordpress.sh\`,
since Pressbooks requires a fresh multisite network of its own).
- Network admin: ${PB_ACCESS_URL}/wp-admin/network/
- Admin user: \`$PB_ADMIN_USER\`
- Admin password: see \`WP_ADMIN_PASSWORD\` in \`.env\`
- Book files: \`html/\`
- Database files: \`db/\`
- PHP limits: \`uploads-ini.d/uploads.ini\` (512M memory, 128M uploads, 600s
execution time — a full-book PDF export can take a while)
## Creating a book
My Sites -> Network Admin -> Sites -> Add New creates a new book (its own
site in the network). Each book gets its own theme, its own chapters, and
its own front/back matter, picked from the Pressbooks admin bar once inside it.
## Writing and placing images
Chapters are written in WordPress's own block editor. Type directly into a
chapter; to place an image, either drag an image file straight into the
content area to drop it in as an Image block exactly where you dropped it,
or use the editor's own Add Media button, which also accepts drag-and-drop
in its upload dialog. Cover images are uploaded the same way from a book's
own Book Info screen.
## Exporting
Export options live under each book's own Export screen.
- **EPUB** — generated directly by Pressbooks, no extra engine needed.
- **PDF** — needs the rendering engine chosen at install time:
$( [[ "$INSTALL_PRINCE" =~ ^[Yy]$ ]] && echo " - PrinceXML is installed on this container.$( [ -n "$PRINCE_LICENSE_PATH" ] && echo " A license file is installed — no watermark." || echo " Free non-commercial version — adds a small logo to page 1 of every PDF; re-run this installer with a purchased license.dat to remove it." )" )
$( [ -n "$DOCRAPTOR_KEY" ] && echo " - DocRaptor is configured as an alternative/fallback (uses your own API key — real documents count against your DocRaptor plan; DocRaptor's own \`test\` mode produces unlimited watermarked previews for free)." )
$( [[ ! "$INSTALL_PRINCE" =~ ^[Yy]$ ]] && [ -z "$DOCRAPTOR_KEY" ] && echo " - Not configured yet — re-run this installer (Update or Full reinstall) to add PrinceXML and/or DocRaptor." )
- **MOBI/Kindle** — Pressbooks removed MOBI export after Amazon discontinued
KindleGen and stopped accepting MOBI on KDP (March 2025). For a personal
Kindle copy, export EPUB and convert it with Calibre — this repo's own
\`calibre-web\` service can do that conversion if you don't already have
Calibre elsewhere.
## Manage
\`\`\`bash
cd $DIR
docker compose up -d # start
docker compose down # stop
docker compose logs -f # logs
docker compose build --pull && docker compose up -d # refresh base image + packages
\`\`\`
Or re-run \`sudo ./setup.sh pressbooks\` and choose Update, which also
refreshes the Pressbooks plugin/themes to their latest release.
## wp-cli
\`\`\`bash
docker run --rm --network $WP_NET -v $DIR/html:/var/www/html \\
--env-file $DIR/.env wordpress:cli wp <command>
\`\`\`
## Backup
\`services/backup.sh\` (Kopia) already covers this directory automatically —
generic for every \`~/docker/*\` directory with a \`docker-compose.yml\`, so
both \`html/\` (every book's content and media) and \`db/\` are captured
together on every run with no per-service setup needed.
MD
echo ""
echo " Access at: $PB_ACCESS_URL"
echo " Network admin: ${PB_ACCESS_URL}/wp-admin/network/"
echo " Admin user: $PB_ADMIN_USER"
echo " Admin pass: $PB_ADMIN_PASS"
echo ""
}
# Run immediately when executed directly (deferred until after function definition)
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_pressbooks
+22 -6
View File
@@ -1436,7 +1436,7 @@ _pstn_check_killswitch_clear() {
# _pstn_install_periodic_timer above) ───────────────────────────────────────
_pstn_write_usage_alert_script() {
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'
#!/bin/bash
# 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 SERVER="$3" SERVER_IPS="$4" DID="$5"
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
@@ -1746,6 +1746,7 @@ _pstn_apply_settings() {
# Direct") — unquoted, bash's `source` would treat the second word of
# any such value as a command to run ("Direct: command not found"),
# confirmed live while testing the multi-IP change.
backup_if_exists "$EA_DIR/.pstn-trunk.env"
cat > "$EA_DIR/.pstn-trunk.env" << ENV
PROVIDER_NAME="${PROVIDER_NAME}"
TRUNK_SERVER="${SERVER}"
@@ -1879,8 +1880,18 @@ install_pstn-trunk() {
local KILLSWITCH_FILE="$ASTERISK_DIR/pstn-trunk-killswitch.conf"
local PERSONAL_DIDS_FILE="$ASTERISK_DIR/pstn-personal-dids.conf"
local SETTINGS_FILE="$EA_DIR/.pstn-trunk.env"
local CONTAINER_NAME="easy-asterisk"
[[ "$ASTERISK_KIND" == "asterisk-digital-ocean" ]] && CONTAINER_NAME="easy-asterisk-do"
# 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 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
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
# detection here, discarding the sourced value, so it can't drift
# from reality and self-heals the persisted file too.
CONTAINER_NAME="easy-asterisk"
[[ "$ASTERISK_KIND" == "asterisk-digital-ocean" ]] && CONTAINER_NAME="easy-asterisk-do"
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
_pstn_check_killswitch_clear "$ASTERISK_DIR"
_pstn_apply_settings "$EA_DIR" "$ASTERISK_DIR" \
"$TRUNK_SERVER" "${TRUNK_SERVER_IPS:-}" "$TRUNK_DID" \
+7
View File
@@ -91,6 +91,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mkdir -p "$_dir"
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
# 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
[ "$_enc" = "n" ] || [ "$_enc" = "N" ] && ENCRYPTED_ONLY="0"
backup_if_exists docker-compose.yml
cat > docker-compose.yml << RD_COMPOSE
name: $CONTAINER
@@ -251,6 +257,7 @@ services:
- ./rustdesk_data:/data
RD_COMPOSE
backup_if_exists .env
cat > .env << RD_ENV
# ── General ───────────────────────────────────────────────────────────────────
TZ=$TZ_VAL
+365
View File
@@ -0,0 +1,365 @@
#!/bin/bash
# services/samba.sh — Samba (SMB/CIFS) file sharing: shares, users, passwords.
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash samba.sh
#
# Samba is a SYSTEM install (apt package + native smbd/nmbd services), NOT a
# docker-compose service — same shape as services/crowdsec.sh. There is no
# ~/docker/samba compose stack; we only create a docs-only folder there with
# a README pointing at the real config under /etc/samba/smb.conf. This is
# the SERVER side — for mounting an existing remote Samba share instead, see
# services/vpn-data-mount.sh (deliberately the opposite: reads an existing
# smb.conf over SSH, never installs Samba, never creates or resets a share
# password).
# ── Standalone bootstrap ──────────────────────────────────────────────────────
# Detected when the script is executed directly rather than sourced by setup.sh.
# Sets up helpers and globals, then defers execution until after the function
# definition at the bottom of this file.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
# Full repo present — use the real helpers (picks up ~/docker/.config too)
# shellcheck source=../lib/common.sh
source "$_COMMON"
else
# One-off copy — inline minimal stubs so the script works without the repo
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
ensure_docker_dir_ownership() {
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_yn() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
generate_password() {
local _len="${1:-32}"
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
}
ensure_ufw_enabled() {
command -v ufw &>/dev/null || return 0
ufw status 2>/dev/null | grep -q "Status: active" && return 0
local _ssh_port
_ssh_port="$(grep -iE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config 2>/dev/null \
| tail -1 | awk '{print $2}')"
_ssh_port="${_ssh_port:-22}"
ufw allow "${_ssh_port}/tcp" comment 'SSH' >/dev/null 2>&1
ufw --force enable >/dev/null 2>&1
log_success "UFW enabled (SSH on port ${_ssh_port} allowed first, so this won't lock you out)."
}
write_readme() {
local _dir="$1"; shift
mkdir -p "$_dir"
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
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
# ($HOME under sudo is /root, not the real user's home)
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
DRY_RUN="${DRY_RUN:-false}"
UNATTENDED="${UNATTENDED:-false}"
register_service() { :; } # no-op — no wizard to register into
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service samba utilities "Samba file sharing (SMB/CIFS) — shares, users, passwords"
install_samba() {
local SMB_CONF="/etc/samba/smb.conf"
local DOCS_DIR="$DOCKER_DIR/samba"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would install samba (smbd/nmbd) if not already present"
echo "[DRY-RUN] Would show any shares this installer already manages"
echo "[DRY-RUN] Would prompt to add one or more shares (path, guest-or-authenticated, users)"
echo "[DRY-RUN] Would create a system Linux account + Samba password for any new user"
echo "[DRY-RUN] Would append share stanzas to $SMB_CONF, validate with testparm, restart smbd/nmbd"
echo "[DRY-RUN] Would open UFW for SMB (137/138 udp, 139/445 tcp) — scoped to the LAN by default"
echo "[DRY-RUN] Would write $DOCS_DIR/README.md (docs only — Samba itself runs natively, not in Docker)"
return 0
fi
if ! command -v smbd &>/dev/null; then
log_info "Installing Samba..."
apt-get update -y
apt-get install -y samba || { log_error "Samba install failed"; return 1; }
log_success "Samba installed"
else
log_success "Samba already installed"
fi
backup_if_exists "$SMB_CONF"
if grep -q '^# ubuntu-post-install:share:' "$SMB_CONF" 2>/dev/null; then
echo ""
log_info "Shares already managed by this installer:"
grep '^# ubuntu-post-install:share:' "$SMB_CONF" | sed 's/^# ubuntu-post-install:share:/ - /'
fi
echo ""
local _added_any=false
while true; do
local ADD_SHARE=""
prompt_yn "Add a Samba share now? (y/n):" "y" ADD_SHARE
[[ "$ADD_SHARE" =~ ^[Yy]$ ]] || break
_samba_add_share "$SMB_CONF" && _added_any=true
echo ""
done
if [ "$_added_any" = true ]; then
log_info "Validating smb.conf..."
if testparm -s "$SMB_CONF" &>/dev/null; then
systemctl restart smbd 2>/dev/null
systemctl restart nmbd 2>/dev/null # NetBIOS name resolution — some Samba packages split this out
log_success "smbd/nmbd restarted with the new configuration"
else
log_error "testparm reports smb.conf is invalid — NOT restarting smbd/nmbd."
log_error "Check manually: sudo testparm -s $SMB_CONF"
return 1
fi
else
log_info "No shares added this run."
fi
_samba_configure_firewall
mkdir -p "$DOCS_DIR"
ensure_docker_dir_ownership "$DOCS_DIR"
write_readme "$DOCS_DIR" << MD
# Samba
Samba runs natively on this box (not in Docker) — the real config is
\`/etc/samba/smb.conf\`, managed by \`systemctl\`. This folder just holds this
README; there's no compose stack here.
## Manage
\`\`\`bash
sudo testparm -s # validate smb.conf before restarting
sudo systemctl restart smbd nmbd
sudo systemctl status smbd
\`\`\`
## Shares
Re-run \`sudo ./setup.sh samba\` (or \`sudo bash services/samba.sh\` standalone)
to add another share or another user — existing shares/users are left alone.
Each share this installer wrote is marked in smb.conf with a
\`# ubuntu-post-install:share:<name>\` comment right above its \`[<name>]\`
stanza, so you can find (or hand-edit / remove) them later.
## Users
Samba users need BOTH a Linux account and a separate Samba password
(\`smbpasswd\`) — they are not the same credential. This installer creates a
system account (\`useradd --system --no-create-home\`, no shell login) for
any username that doesn't already exist as a Linux user, adds it to the
\`sambashare\` group, and sets its Samba password with \`smbpasswd\`.
\`\`\`bash
sudo smbpasswd <username> # change an existing user's Samba password
sudo pdbedit -L # list all Samba users
sudo smbpasswd -x <username> # remove a user from Samba (leaves the Linux account alone)
\`\`\`
## Connecting
- Windows: \`\\\\<server-ip>\\<share-name>\`
- macOS Finder: Go -> Connect to Server -> \`smb://<server-ip>/<share-name>\`
- Linux: \`smbclient //<server-ip>/<share-name> -U <username>\` or mount with
\`mount.cifs\` / \`cifs-utils\` (already installed by \`services/base.sh\`).
## Firewall
SMB (137/138 UDP, 139/445 TCP) should almost never be exposed to the public
internet — this installer scopes the UFW rule to your LAN subnet by default.
Check what's currently allowed with \`sudo ufw status | grep -E '13[7-9]|445'\`.
MD
log_success "Samba configured. Re-run 'sudo ./setup.sh samba' any time to add another share or user."
}
# Appends one [share] stanza to smb.conf. Returns non-zero (and adds nothing)
# on a blank/duplicate name so the caller's "did we actually add one" tracking
# stays accurate.
_samba_add_share() {
local _conf="$1"
local NAME="" SHARE_PATH="" GUEST=""
prompt_text " Share name (letters/numbers/hyphens/underscores, e.g. media):" "" NAME
NAME="$(echo "$NAME" | tr -cd 'A-Za-z0-9_-')"
if [[ -z "$NAME" ]]; then
log_warning "Share name required — skipping."
return 1
fi
if grep -q "^\[$NAME\]\$" "$_conf" 2>/dev/null; then
log_warning "A share named [$NAME] already exists in smb.conf — skipping."
log_warning "Edit $_conf by hand to change it, or pick a different name."
return 1
fi
local DEFAULT_PATH="/srv/samba/$NAME"
prompt_text " Path to share [$DEFAULT_PATH]:" "$DEFAULT_PATH" SHARE_PATH
SHARE_PATH="${SHARE_PATH:-$DEFAULT_PATH}"
SHARE_PATH="${SHARE_PATH/#\~/$ACTUAL_HOME}"
mkdir -p "$SHARE_PATH"
prompt_yn " Allow guest (no password) access to '$NAME'? (y/n):" "n" GUEST
local VALID_USERS=""
if [[ ! "$GUEST" =~ ^[Yy]$ ]]; then
echo " Enter Samba usernames to grant access to '$NAME' (blank to stop):"
while true; do
local SUSER=""
prompt_text " Username:" "" SUSER
[[ -z "$SUSER" ]] && break
_samba_ensure_user "$SUSER"
VALID_USERS="${VALID_USERS:+$VALID_USERS }$SUSER"
done
if [[ -z "$VALID_USERS" ]]; then
log_warning "No users added and guest access declined — '$NAME' will be inaccessible until you add a user (re-run this installer, or edit smb.conf by hand)."
fi
fi
getent group sambashare >/dev/null 2>&1 || groupadd sambashare
if [[ "$GUEST" =~ ^[Yy]$ ]]; then
chmod 0777 "$SHARE_PATH"
else
chgrp sambashare "$SHARE_PATH" 2>/dev/null || true
chmod 0770 "$SHARE_PATH"
fi
{
echo ""
echo "# ubuntu-post-install:share:$NAME"
echo "[$NAME]"
echo " path = $SHARE_PATH"
echo " browseable = yes"
echo " read only = no"
if [[ "$GUEST" =~ ^[Yy]$ ]]; then
echo " guest ok = yes"
else
echo " guest ok = no"
[[ -n "$VALID_USERS" ]] && echo " valid users = $VALID_USERS"
fi
} >> "$_conf"
log_success "Share '$NAME' -> $SHARE_PATH added to smb.conf"
}
# Creates the Linux system account (if missing) and sets a Samba password for
# it. Samba users need BOTH — a Linux account and a separate smbpasswd entry
# — they are not the same credential, and smbpasswd -a fails outright against
# a username with no matching Linux account at all.
_samba_ensure_user() {
local _user="$1"
if ! id "$_user" &>/dev/null; then
log_info "Linux account '$_user' doesn't exist — creating a system account (no shell login, no home dir)."
useradd --system --no-create-home --shell /usr/sbin/nologin "$_user"
fi
getent group sambashare >/dev/null 2>&1 || groupadd sambashare
usermod -aG sambashare "$_user"
if pdbedit -L 2>/dev/null | cut -d: -f1 | grep -qx "$_user"; then
log_info "Samba password already set for '$_user' — leaving as-is (change it later with: sudo smbpasswd $_user)."
return 0
fi
local _pass _entered=""
_pass="$(generate_password 16)"
prompt_text " Samba password for '$_user' [$_pass]:" "$_pass" _entered
_pass="${_entered:-$_pass}"
if printf '%s\n%s\n' "$_pass" "$_pass" | smbpasswd -s -a "$_user" >/dev/null 2>&1 \
&& smbpasswd -e "$_user" >/dev/null 2>&1; then
log_success "Samba user '$_user' set — password: $_pass (write this down, it isn't stored anywhere else)"
else
log_warning "Failed to set Samba password for '$_user' — set it manually: sudo smbpasswd $_user"
fi
}
# SMB should almost never face the public internet — scope the UFW rule to
# the LAN by default (LAN-subnet detection borrowed from the same pattern
# services/asterisk.sh uses for its VLAN/local-network prompt).
_samba_configure_firewall() {
command -v ufw &>/dev/null || {
log_warning "ufw not installed — if you use a firewall, open TCP 139/445 and UDP 137/138 for SMB (LAN only, never the internet)."
return 0
}
echo ""
local DETECTED_NETS DEFAULT_SUBNET=""
DETECTED_NETS="$(ip -o -f inet addr show scope global 2>/dev/null \
| awk '{print $2, $4}' \
| grep -Ev '^(docker|br-|veth|tun|tap|wg)' \
| awk '{ split($2,a,"/"); split(a[1],o,"."); print o[1]"."o[2]"."o[3]".0/"a[2] }' \
| sort -u)"
DEFAULT_SUBNET="$(echo "$DETECTED_NETS" | head -1)"
local RESTRICT_LAN=""
prompt_yn "Restrict Samba access to your local network only (recommended — SMB should never face the internet)? (y/n):" "y" RESTRICT_LAN
if [[ "$RESTRICT_LAN" =~ ^[Yy]$ ]]; then
local SUBNET=""
prompt_text " LAN subnet to allow (CIDR)${DEFAULT_SUBNET:+ [$DEFAULT_SUBNET]}:" "$DEFAULT_SUBNET" SUBNET
SUBNET="${SUBNET:-$DEFAULT_SUBNET}"
if [[ -z "$SUBNET" ]]; then
log_warning "No subnet given — skipping UFW rules. Open them manually if needed."
return 0
fi
local p
for p in 137 138; do
ufw allow from "$SUBNET" to any port "$p" proto udp comment "Samba (LAN)" >/dev/null 2>&1
done
for p in 139 445; do
ufw allow from "$SUBNET" to any port "$p" proto tcp comment "Samba (LAN)" >/dev/null 2>&1
done
log_success "UFW: Samba opened to $SUBNET only"
else
log_warning "Opening Samba to ALL sources — not recommended, SMB has a long history of remote exploits."
ufw allow 137/udp comment "Samba" >/dev/null 2>&1
ufw allow 138/udp comment "Samba" >/dev/null 2>&1
ufw allow 139/tcp comment "Samba" >/dev/null 2>&1
ufw allow 445/tcp comment "Samba" >/dev/null 2>&1
log_success "UFW: Samba opened (unrestricted)"
fi
ensure_ufw_enabled
}
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_samba
+214 -10
View File
@@ -105,7 +105,15 @@ install_security-dashboard() {
if [[ "$ASTERISK_EA_DIR" == *asterisk-digital-ocean ]]; then
ASTERISK_EA_CONTAINER="easy-asterisk-do"
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
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
if [[ "$_reconf" =~ ^[Yy]$ ]]; then
_secdash_remove_caddy_block "$DASHBOARD_PORT"
_secdash_configure_caddy "$DASHBOARD_PORT"
_secdash_configure_caddy "$DASHBOARD_PORT" "$ASTERISK_EA_DIR" "$ASTERISK_EA_CONTAINER"
fi
echo ""
@@ -253,7 +261,7 @@ install_security-dashboard() {
# 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.
_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"
write_readme "$APP_DIR" << README_MD
@@ -678,7 +686,7 @@ SUDOERS
# retroactively) using the exact same code path as a fresh install, instead
# of hand-patching a live Caddyfile block in place.
_secdash_configure_caddy() {
local DASHBOARD_PORT="$1"
local DASHBOARD_PORT="$1" ASTERISK_EA_DIR="${2:-}" ASTERISK_EA_CONTAINER="${3:-}"
echo ""
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
fi
local _default_domain=""
if [ -n "${SITE_DOMAIN:-}" ] && [ "$SITE_DOMAIN" != "example.com" ]; then
_default_domain="security.${SITE_DOMAIN}"
fi
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
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."
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,
# via host.docker.internal) ever needs to reach it.
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)."
}
# 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
# everything a fresh install recreates: systemd unit, sudoers grant, Caddy
# 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"])
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):
"""Sets/clears the voicemail flag for one extension, then regenerates
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
later reuses the same PIN instead of silently changing it on the user.
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:
return False, "No Asterisk install detected on this box"
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
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"
+6
View File
@@ -60,6 +60,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mkdir -p "$_dir"
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
# 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_SECRET, blank to skip:" "" FF_SECRET
if [ -n "$FF_KEY" ] && [ -n "$FF_SECRET" ]; then
backup_if_exists "$SS_DIR/.env"
cat > "$SS_DIR/.env" << ENVEOF
WEB_EXT_API_KEY="$FF_KEY"
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
echo "easy-asterisk-do"
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
}
@@ -629,17 +637,11 @@ CBLOCK
_sms_write_readme() {
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
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
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
local _url_section
if [ -n "$_url" ]; then
_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 checkbox, paste, press SAVE — RETURN discards):
\`\`\`
@@ -652,7 +654,21 @@ query parameters; with the message last, everything after it can be read back
verbatim.
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
@@ -770,8 +786,24 @@ install_sms-inbound() {
&& log_success "Relay refreshed and restarted." \
|| log_warning "Restart failed — check: journalctl -u sms-inbound -n 50"
echo ""
log_success "Settings, Caddy and firewall rules were left untouched."
echo " Provider URL: ${SMS_FORWARD_URL}"
# A missing/placeholder domain here means an earlier run was
# 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 ""
return 0
;;
@@ -898,7 +930,14 @@ install_sms-inbound() {
ensure_ufw_enabled
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 ──────────────────────────────────────────────────────
# Single-quoted values: this file gets `source`d again on the next
@@ -929,6 +968,14 @@ ENV
# ── Summary ───────────────────────────────────────────────────────────────
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."
echo ""
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"
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
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -242,6 +247,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << PDF_COMPOSE
name: stirling-pdf
@@ -261,6 +267,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
PDF_COMPOSE
backup_if_exists .env
cat > .env << PDF_ENV
# Stirling PDF configuration
+7
View File
@@ -147,6 +147,11 @@ CBLOCK
mkdir -p "$_dir"
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
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -225,6 +230,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << EOF
name: syncthing
@@ -249,6 +255,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
EOF
backup_if_exists .env
cat > .env << ENV
CADDY_NET=$SITE_CADDY_NET
PUID=$PUID
+7
View File
@@ -180,6 +180,11 @@ CBLOCK
mkdir -p "$_dir"
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
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -474,6 +479,7 @@ networks:
- \"${PROTO_MIN}-${PROTO_MAX}:${PROTO_MIN}-${PROTO_MAX}/udp\""
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << TRACCAR_COMPOSE
name: $CONTAINER
@@ -560,6 +566,7 @@ SMS_HTTP_PASSWORD=$SMS_HTTP_PASSWORD
fi
fi
backup_if_exists .env
cat > .env << TRACCAR_ENV
TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET
+7
View File
@@ -89,6 +89,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mkdir -p "$_dir"
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
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -238,6 +243,7 @@ networks:
fi
# 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
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"}]});
UNIFI_COMPOSE
backup_if_exists .env
cat > .env << UNIFI_ENV
# ── General ───────────────────────────────────────────────────────────────────
TZ=$TZ_VAL
+57 -3
View File
@@ -177,6 +177,11 @@ CBLOCK
mkdir -p "$_dir"
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
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -206,6 +211,9 @@ install_uptimekuma() {
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $UPTIME_DIR"
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
fi
@@ -241,6 +249,40 @@ networks:
"
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
name: uptime-kuma
@@ -250,7 +292,7 @@ services:
container_name: uptime-kuma
hostname: uptime-kuma
restart: unless-stopped
volumes:
${UPTIME_ENV_SECTION} volumes:
- ./data:/app/data
- /var/run/docker.sock:/var/run/docker.sock:ro
ports:
@@ -282,6 +324,15 @@ Docker containers.
If Caddy is installed, you can expose this via the prompt during install
(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
\`\`\`
cd $UPTIME_DIR
@@ -291,8 +342,11 @@ docker compose logs -f # logs
\`\`\`
MD
# Configure Caddy reverse proxy before starting
configure_caddy_for_service "Uptime Kuma" "uptime-kuma:3001" "uptime"
# Configure Caddy reverse proxy before starting (skip if the Authelia
# 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=""
prompt_yn "Start Uptime Kuma now? (y/n):" "y" START_UPTIME
+34
View File
@@ -194,6 +194,11 @@ CBLOCK
mkdir -p "$_dir"
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
# 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
# just at install time, so it self-heals regardless of how the box got into
# 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() {
local env_file="$1"
[ -f "$env_file" ] || return 0
@@ -298,6 +319,7 @@ install_vaultwarden() {
case "$MODE" in
update)
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"
( cd "$VW_DIR" && docker compose pull && docker compose up -d ) \
&& log_success "Vaultwarden image refreshed" \
@@ -336,6 +358,15 @@ install_vaultwarden() {
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
[ -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 " SMTP (optional) — for password-reset and invite emails."
@@ -394,6 +425,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << VW_COMPOSE
name: $CONTAINER
@@ -411,6 +443,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
VW_COMPOSE
backup_if_exists .env
cat > .env << VW_ENV
# ── General ───────────────────────────────────────────────────────────────────
TZ=$TZ_VAL
@@ -487,6 +520,7 @@ MD
local START_VW=""
prompt_yn "Start Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_VW
if [ "$START_VW" = "y" ] || [ "$START_VW" = "Y" ]; then
_vaultwarden_fix_domain_scheme "$VW_DIR/.env"
_vaultwarden_fix_smtp_halfstate "$VW_DIR/.env"
docker compose up -d \
&& log_success "Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \
+7
View File
@@ -64,6 +64,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mkdir -p "$_dir"
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
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -151,6 +156,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << WT_COMPOSE
name: watchtower
@@ -179,6 +185,7 @@ ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
WT_COMPOSE
# Create .env
backup_if_exists .env
cat > .env << WT_ENV
# Watchtower Configuration
# =========================
+7
View File
@@ -68,6 +68,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mkdir -p "$_dir"
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
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -126,6 +131,7 @@ install_watchyourlan() {
prompt_text "GUI port [8840]:" "8840" GUI_PORT
[ -z "$GUI_PORT" ] && GUI_PORT="8840"
backup_if_exists docker-compose.yml
cat > docker-compose.yml << 'WYL_COMPOSE'
name: watchyourlan
@@ -141,6 +147,7 @@ services:
- ./watchyourlan_data:/data
WYL_COMPOSE
backup_if_exists .env
cat > .env << WYL_ENV
# ── General ───────────────────────────────────────────────────────────────────
TZ=$TZ_VAL
+7
View File
@@ -199,6 +199,11 @@ CBLOCK
mkdir -p "$_dir"
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
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -302,6 +307,7 @@ networks:
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << WGEASY_COMPOSE
name: wg-easy
@@ -332,6 +338,7 @@ services:
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
WGEASY_COMPOSE
backup_if_exists .env
cat > .env << WGEASY_ENV
WG_HOST=$WG_HOST
# Plain-text password — used only if PASSWORD_HASH could not be generated above
+58 -3
View File
@@ -169,6 +169,24 @@ CBLOCK
mkdir -p "$_dir"
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
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -201,11 +219,29 @@ install_wolf-pair() {
echo " - Build the wolf-pair image (python:3.12-alpine + docker-cli)"
echo " - Run the container with network_mode: host (for localhost:47989 access)"
echo " - Mount /var/run/docker.sock:ro (for docker logs wolf)"
echo " - Open port $WOLFPAIR_PORT in UFW"
echo " - Open port $WOLFPAIR_PORT in UFW (auto-scanned for a free host port —"
echo " other services, e.g. wordpress/ntfy/beszel, default to 8090 too)"
echo " - Optionally configure a Caddy reverse proxy"
return 0
fi
# network_mode: host means there's no HOST:CONTAINER ports: mapping to scan
# around a collision on — server.py binds 0.0.0.0 directly on the host, so a
# taken 8090 (wordpress/ntfy/beszel all default here too) fails at container
# start with "address already in use" and nothing in docker-compose.yml to
# point at. Scan once and persist in .env; on a rerun, keep the port already
# in use rather than silently moving it out from under an existing Caddy
# site block / bookmarked URL.
if [ -f "$WOLFPAIR_DIR/.env" ]; then
local _existing_port
_existing_port="$(grep '^WOLFPAIR_PORT=' "$WOLFPAIR_DIR/.env" 2>/dev/null | cut -d= -f2-)"
[ -n "$_existing_port" ] && WOLFPAIR_PORT="$_existing_port"
else
find_free_port WOLFPAIR_PORT "$WOLFPAIR_PORT"
fi
[ "$WOLFPAIR_PORT" != "8090" ] && \
log_info "Port 8090 already in use — wolf-pair will use $WOLFPAIR_PORT instead."
mkdir -p "$WOLFPAIR_DIR"
ensure_docker_dir_ownership "$WOLFPAIR_DIR"
cd "$WOLFPAIR_DIR" || return 1
@@ -229,10 +265,11 @@ submitted — otherwise the user resubmits a dead secret and Wolf returns
"key not found". We track submitted secrets and fall back to the waiting page
until Moonlight initiates a brand-new pairing (which mints a new secret).
"""
import json, subprocess, re, urllib.request, urllib.error
import json, os, subprocess, re, urllib.request, urllib.error
from http.server import HTTPServer, BaseHTTPRequestHandler
WOLF_HTTP = "http://localhost:47989"
LISTEN_PORT = int(os.environ.get("WOLFPAIR_PORT", "8090"))
# Secrets already submitted to Wolf. Wolf erases a secret on first submit, so a
# secret in here is dead — show the waiting page instead of re-offering it.
@@ -385,7 +422,7 @@ class Handler(BaseHTTPRequestHandler):
if __name__ == '__main__':
HTTPServer.allow_reuse_address = True
HTTPServer(('0.0.0.0', 8090), Handler).serve_forever()
HTTPServer(('0.0.0.0', LISTEN_PORT), Handler).serve_forever()
PYEOF
log_success "server.py written"
@@ -404,6 +441,7 @@ DOCKERFILE
# network_mode: host — server.py reaches Wolf at localhost:47989 directly.
# Docker socket (ro) — server.py calls `docker logs wolf` to read secrets.
log_info "Writing docker-compose.yml..."
backup_if_exists "$WOLFPAIR_DIR/docker-compose.yml"
cat > "$WOLFPAIR_DIR/docker-compose.yml" << 'COMPOSE'
name: wolf-pair
@@ -414,12 +452,27 @@ services:
dockerfile: Dockerfile
container_name: wolf-pair
network_mode: host
environment:
- WOLFPAIR_PORT=${WOLFPAIR_PORT:-8090}
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
restart: unless-stopped
COMPOSE
log_success "docker-compose.yml written"
# host networking means server.py binds this port directly — .env feeds it
# to the container's WOLFPAIR_PORT (above) via docker compose's own .env
# auto-load, same pattern as WOLF_STATE_DIR in services/wolf.sh.
backup_if_exists "$WOLFPAIR_DIR/.env"
cat > "$WOLFPAIR_DIR/.env" << EOF
# Port wolf-pair's pairing UI listens on (host networking — no port mapping
# to edit). Auto-scanned at install time to avoid clashing with other
# services that also default to 8090 (wordpress, ntfy, beszel).
WOLFPAIR_PORT=${WOLFPAIR_PORT}
EOF
chmod 600 "$WOLFPAIR_DIR/.env"
chown "$ACTUAL_USER:$ACTUAL_USER" "$WOLFPAIR_DIR/.env"
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WOLFPAIR_DIR"
# ── 4. Caddy (optional) ───────────────────────────────────────────────────
@@ -485,6 +538,8 @@ docker compose logs -f # follow logs
- If you set up a Caddy subdomain (e.g. `wolf-pair.yourdomain.com`), that
subdomain is for the PIN form only.
MD
[ "$WOLFPAIR_PORT" != "8090" ] && \
sed -i "s/localhost:8090/localhost:${WOLFPAIR_PORT}/g" "$WOLFPAIR_DIR/README.md"
# ── 7. Build & start ──────────────────────────────────────────────────────
echo ""
+2928 -100
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -207,6 +207,11 @@ CBLOCK
cat > "$_dir/README.md"
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
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
@@ -361,6 +366,7 @@ PHPINI
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << WPCOMPOSE
name: $CONTAINER
@@ -398,6 +404,7 @@ networks:
${_CADDY_NET_SECTION}
WPCOMPOSE
backup_if_exists .env
cat > .env << WPENV
TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET
+28 -1
View File
@@ -26,6 +26,31 @@ set -uo pipefail
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
# may be unset, causing raw-mode to fail and arrow keys to leak to the shell.
export TERM="${TERM:-xterm-256color}"
@@ -96,10 +121,12 @@ is_installed() {
base) command -v ncdu >/dev/null 2>&1 ;;
glow) command -v glow >/dev/null 2>&1 ;;
crowdsec) command -v cscli >/dev/null 2>&1 ;;
samba) command -v smbd >/dev/null 2>&1 ;;
security-dashboard) [ -f /opt/security-dashboard/app.py ] ;;
kdeconnect) command -v kdeconnect >/dev/null 2>&1 ;;
silent-send) [ -d "$ACTUAL_HOME/silent-send/.git" ] ;;
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-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
@@ -130,7 +157,7 @@ is_installed() {
# is_installed() as 0 or 1.
install_count() {
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|samba|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 ;;
wordpress)
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 ─────────────────────────────────────────
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
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 " $PASS passed, $WARN warnings, $FAIL failed. Stopping — nothing else can be checked without a running container."
exit 1
@@ -196,8 +200,7 @@ if [ -z "$TURN_SERVER" ]; then
warn "Add it via: sudo ./setup.sh asterisk (update mode)"
else
if grep -q '^ coturn:' "$EA_DIR/docker-compose.yml" 2>/dev/null; then
COTURN_CONTAINER="easy-asterisk-coturn"
[[ "$CONTAINER" == *-do ]] && COTURN_CONTAINER="easy-asterisk-do-coturn"
COTURN_CONTAINER="${CONTAINER}-coturn"
ok "Using an embedded, per-Asterisk coturn ($COTURN_CONTAINER); tested separately below."
else
COTURN_CONTAINER="coturn"
+47 -15
View File
@@ -277,17 +277,34 @@ sync_github_to_gitea() {
# old commit indefinitely, with no error at any step. Also no longer
# silencing stderr: a real auth/network failure should be visible in the
# log, not just "Failed to fetch" with no reason why.
local auth_url="${clone_url/https:\/\//https:\/\/$GITHUB_TOKEN@}"
if [[ -d "$local_path" ]]; then
info "Fetching $full_name from GitHub..."
git -C "$local_path" fetch origin '+refs/heads/*:refs/heads/*' --prune --quiet || {
err "Failed to fetch $full_name"; return 1; }
else
info "Cloning $full_name from GitHub..."
mkdir -p "$(dirname "$local_path")"
local auth_url="${clone_url/https:\/\//https:\/\/$GITHUB_TOKEN@}"
git clone --bare --quiet "$auth_url" "$local_path" || {
err "Failed to clone $full_name"; return 1; }
git init --bare --quiet "$local_path" || { err "Failed to init $full_name"; return 1; }
git -C "$local_path" remote add origin "$auth_url"
fi
# Explicit heads+tags refspec on BOTH the initial clone and every later
# fetch, not `git clone --bare` (which pulls every ref the remote
# advertises, refs/pull/*/head included) — GitHub exposes PR refs over
# the same smart-HTTP endpoint a plain bare clone reads from, and those
# live in a namespace Gitea's own PR system reserves for itself. A later
# `git push --mirror` (pushes every local ref verbatim) then gets
# rejected by Gitea's server-side hook — confirmed live: "hook declined
# to update refs/pull/1/head". Scoping fetch AND push to heads/tags only
# avoids ever touching that namespace in either direction.
git -C "$local_path" fetch origin \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' \
--prune --quiet || { err "Failed to fetch $full_name"; return 1; }
# Self-heals a repo synced before this fix — a stray refs/pull/* (or any
# other non-heads/tags ref) an earlier run's unscoped `clone --bare`
# already pulled in would otherwise keep tripping the same Gitea hook on
# every sync from here on, with no other way to clear it.
git -C "$local_path" for-each-ref --format='%(refname)' \
'refs/pull/*' 'refs/merge-requests/*' 'refs/changes/*' \
| xargs -r -n1 git -C "$local_path" update-ref -d
# Ensure repo exists on Gitea
local gitea_check
@@ -299,12 +316,17 @@ sync_github_to_gitea() {
>/dev/null || { err "Failed to create $repo_name on Gitea"; return 1; }
fi
# Push to Gitea
# Push to Gitea — same explicit heads+tags scoping as the fetch above,
# not --mirror (which would push refs/pull/* etc. verbatim and hit the
# same rejected-hook failure this whole fix is for). --prune still makes
# Gitea's heads/tags a true mirror of GitHub's (deletes ones GitHub no
# longer has), just without ever touching reserved ref namespaces.
local gitea_push_url="${GITEA_URL/https:\/\//https:\/\/$GITEA_USER:$GITEA_TOKEN@}"
gitea_push_url="${gitea_push_url/http:\/\//http:\/\/$GITEA_USER:$GITEA_TOKEN@}"
gitea_push_url="$gitea_push_url/$GITEA_USER/$repo_name.git"
git -C "$local_path" push --mirror "$gitea_push_url" --quiet || {
git -C "$local_path" push --prune --quiet "$gitea_push_url" \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' || {
err "Failed to push $full_name to Gitea"; return 1; }
ok "GitHub → Gitea: $full_name"
_log "PULL $full_name OK"
@@ -320,18 +342,26 @@ sync_gitea_to_github() {
local gitea_auth_url="${clone_url/https:\/\//https:\/\/$GITEA_USER:$GITEA_TOKEN@}"
gitea_auth_url="${gitea_auth_url/http:\/\//http:\/\/$GITEA_USER:$GITEA_TOKEN@}"
# See the matching comment in sync_github_to_gitea() above — same
# explicit-refspec, visible-stderr fix, same reason.
# See the matching comment in sync_github_to_gitea() above — same reason
# applies in reverse: Gitea also exposes PR refs (refs/pull/*/head) over
# its git smart-HTTP endpoint, and GitHub rejects direct pushes to that
# same reserved namespace just as Gitea's hook does. Explicit heads+tags
# refspec on the initial clone too, not `git clone --bare`.
if [[ -d "$local_path" ]]; then
info "Fetching $full_name from Gitea..."
git -C "$local_path" fetch origin '+refs/heads/*:refs/heads/*' --prune --quiet || {
err "Failed to fetch $full_name from Gitea"; return 1; }
else
info "Cloning $full_name from Gitea..."
mkdir -p "$(dirname "$local_path")"
git clone --bare --quiet "$gitea_auth_url" "$local_path" || {
err "Failed to clone $full_name from Gitea"; return 1; }
git init --bare --quiet "$local_path" || { err "Failed to init $full_name"; return 1; }
git -C "$local_path" remote add origin "$gitea_auth_url"
fi
git -C "$local_path" fetch origin \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' \
--prune --quiet || { err "Failed to fetch $full_name from Gitea"; return 1; }
# Self-heals a repo synced before this fix — see the matching comment above.
git -C "$local_path" for-each-ref --format='%(refname)' \
'refs/pull/*' 'refs/merge-requests/*' 'refs/changes/*' \
| xargs -r -n1 git -C "$local_path" update-ref -d
# Ensure repo exists on GitHub
local gh_check
@@ -343,9 +373,11 @@ sync_gitea_to_github() {
>/dev/null || { err "Failed to create $repo_name on GitHub"; return 1; }
fi
# Push to GitHub
# Push to GitHub — explicit heads+tags scoping, not --mirror. Same
# reasoning as the Gitea push above.
local github_push_url="https://$GITHUB_TOKEN@github.com/$GITHUB_USER/$repo_name.git"
git -C "$local_path" push --mirror "$github_push_url" --quiet || {
git -C "$local_path" push --prune --quiet "$github_push_url" \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' || {
err "Failed to push $full_name to GitHub"; return 1; }
ok "Gitea → GitHub: $full_name"
_log "PUSH $full_name OK"
+27 -2
View File
@@ -596,7 +596,9 @@ services:
capabilities: [gpu]
healthcheck:
test: ["CMD","ollama","list"]
interval: 30s; timeout: 10s; retries: 5
interval: 30s
timeout: 10s
retries: 5
open-webui:
image: ghcr.io/open-webui/open-webui:main
@@ -630,7 +632,9 @@ services:
- ANONYMIZED_TELEMETRY=FALSE
healthcheck:
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:
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
[[ "\${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
PULLSH
chmod +x "$BASE/pull-models.sh"