59f82c57a62ecfc86bf20a280258031b98efd1a4
399
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
59f82c57a6 |
Self-heal a half-set SMTP_HOST/SMTP_FROM in Vaultwarden's .env
Vaultwarden crash-loops outright if exactly one of SMTP_HOST/SMTP_FROM is
set ("Both SMTP_HOST and SMTP_FROM need to be set for email support
without USE_SENDMAIL"). The fresh-install prompt flow already avoids ever
writing that half-state, but "update" mode deliberately never touches
.env (same rule as everywhere else in this repo), so a box whose .env was
written before that prompt-side fix existed - or hand-edited since - stays
stuck crash-looping on every future update too, since nothing ever
re-checked it. Confirmed live on a real box.
New _vaultwarden_fix_smtp_halfstate() detects the half-set state and
blanks the whole SMTP block (matching what the fresh-install prompt does
when SMTP is skipped) rather than leaving it broken. Called right before
every docker compose up this file does - the update path (previously
unguarded) and the fresh-install start prompt (defense in depth, since
that path is already safe by construction) - so it self-heals regardless
of how a box got into this state.
Audited every other services/*.sh for the same half-set-required-pair
pattern (SMTP, MAIL_*, SMTP_HOST-style naming) - Vaultwarden is the only
one that actually writes paired config where a partial state crashes the
container. Authelia's SMTP is mandatory-with-defaults (a different,
non-crashing risk); Mattermost/frigate-notify only mention SMTP in
generated docs, never in config they write.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
|
||
|
|
d1d234b4d2 |
Fix Gatus false-positive red on Authelia-protected and stale-synced sites
The auto-sync condition "[STATUS] < 400" reads as red for any site behind Authelia's forward_auth: Gatus's probe is never logged in, so it correctly gets a 401 back every time — the site is completely healthy, Authelia is just doing its job, but that 401 fails the condition. Confirmed live: every site the user actually logs into showed permanently red. That single condition also had the opposite bug in reserve: on a genuine outage (connection refused, DNS failure, TLS failure), Gatus reports [STATUS] as 0, and 0 < 400 is true — a fully unreachable site would have silently read as "up". Fixed to two conditions together: "[CONNECTED] == true" (catches the actual outage case) and "[STATUS] < 500" (accepts any real response, including 401/403/redirects from an auth gate, only failing on Caddy's own 502/503/504 when the backend itself is unreachable). Also changed the sync loop to refresh conditions on already-synced endpoints, not just add-missing-ones — the old add-if-missing-only logic meant this fix would only apply to newly discovered domains, leaving every already-synced site (which is most of them, on a live box) stuck on the broken condition forever until removed and re-added by hand. Now every sync run (every 15 minutes via the existing systemd timer, or the one that happens immediately on a Gatus reinstall) self-heals all of them. Verified end-to-end against the real mikefarah/yq binary: an existing caddy-sync entry gets its conditions rewritten in place, an unrelated manually-added endpoint is left untouched, and a newly-discovered domain gets the corrected conditions from the start. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
459bde0f38 |
Make tab completion + backup pruning setup unconditional in setup.sh
Both were only ever wired up from inside install_base(), so a box that went straight to a direct single-service install (sudo ./setup.sh beszel-agent, or any other service) without first explicitly running `sudo ./setup.sh base` never got either — the direct-install branch exits before the guided flow's own `run_service base` call is ever reached. Confirmed live: tab completion doesn't work on a fresh box that installed beszel-agent first. Moved the call site to setup.sh itself, right after the --list/--status early exits (which stay read-only and don't require root) and before every other branch (configure, --remove, direct install, guided flow) — all of which are downstream of that point regardless of which one actually runs. Both helpers are idempotent and already no-prompt by design, so calling them unconditionally on every invocation is safe; skipped under --dry-run (with an equivalent [DRY-RUN] message) so a preview run doesn't write real files. install_base()'s own calls to both are now fully redundant (base.sh has no standalone-bootstrap block, so install_base() is only ever reached downstream of setup.sh's new call site) and removed, along with the two DRY-RUN preview lines that described them there. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
f7cc0e5bc4 |
Add beszel-agent: agent-only Beszel install for remote/homelab boxes
For monitoring a box that isn't the VPS (e.g. a homelab machine): only the agent needs to run there, and it connects OUTBOUND to the hub over HTTPS using the same key + universal token flow the hub-side installer already uses — no VPN, no router port-forwarding, and no FQDN needed on that box, since nothing on it ever needs to be reached FROM the hub. New register_service beszel-agent in services/beszel.sh (a second registration in the same file, precedented by base.sh's base+glow) reuses _beszel_configure_agent's paste/parse UX for the key/token instead of duplicating it — that function's signature changed from a bare hub port to a full login-URL string so both the local-hub path and this new agent-only path can share it. Run on the remote box: sudo ./setup.sh beszel-agent Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
c719197b20 |
Admin-scoping setup: show live extension list, auto-include owned DIDs
Two refinements to the per-admin extension scoping added last commit: - The setup prompt now shows the dashboard's current extensions (pulled from its own running /api/pstn-permissions, reusing list_extensions()'s already-correct pjsip.conf parsing instead of a second implementation in bash) before asking for each admin's list, with a real example built from actual extension numbers instead of a generic placeholder. Shown fresh for every admin added, one at a time. - An admin scoped to an extension now automatically sees that extension's directly-assigned personal DID's call/text history too, not just its internal activity — parse_pstn_calls()/parse_texts() key inbound rows by the DID that was dialed, not the owning extension, so without this a scoped admin would see their own extension's outbound calls but not inbound calls to their own number. New _dids_for_extensions()/ _admin_scope_for_calls() resolve this per-request from pstn-personal-dids.conf's direct (non-ring-group) owner field. Voicemail scoping is unaffected — a mailbox is always keyed by extension number regardless of which DID rang it. Also removed a dead DASHBOARD_ADMINS_HEADER Python constant left over from before the file-writing responsibility settled on the bash side only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
d124902b8d |
Add fail-closed per-admin extension scoping for Calls & Texts and Voicemail
Two admins sharing one dashboard can now each be scoped to their own extensions on the Calls & Texts and Voicemail tabs, while Security Log, CrowdSec, and Extensions stay fully visible to both — Authelia already provides real per-person identity here (Remote-User, forwarded by Caddy's existing forward_auth/import authelia wiring), this just teaches app.py to finally read it for these two tabs instead of ignoring it. New dashboard-admins.conf ([username] -> extensions=), configured via CLI prompts in security-dashboard.sh (offered at install and on reconfigure), read-only from app.py's side — no write access needed since the file is root-managed. allowed_extensions_for_user() is fail-closed by design: an empty/missing file means unrestricted (today's default, unchanged), but the moment one admin is configured, every other identity — an unlisted admin, a typo, or no Authelia identity at all — sees nothing on those two tabs until added. /voicemail/audio checks the same scope directly (not just the list route) so a guessed or copied URL can't bypass the filter. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
8950810cea |
Add voicemail: dialplan/mailboxes in Asterisk, Extensions toggle + Voicemail tab with click-to-play in the dashboard
Asterisk side (services/asterisk.sh): a [voicemail-access] context reachable from every extension (*97 checks your own mailbox, *98<ext> drops a message into another mailbox directly), gated live via AST_CONFIG() on a new "voicemail" flag in pstn-permissions.conf. voicemail.conf gets a skeleton [general]+[default] at install/update, then stays dashboard-owned from there — mailbox lines are never regenerated wholesale by asterisk.sh once the file exists, matching every other install-time-vs-dashboard-owned file split in this repo (.env, firewall rules, etc). Vendor files (entrypoint.sh, easy-asterisk.sh) get patched the same way messaging-dialplan.conf already does, including the live-extensions.conf patch for boxes with existing devices. While tracing the right #include anchor for this, found and then reverted a theoretical "fix" to messaging's own #include position: pstn-trunk.sh's own comment (live-confirmed 2026-07-24) directly contradicts the textbook Asterisk #include semantics I'd assumed, so the safer move was keeping messaging's anchor exactly as already verified working and using the same position for voicemail's own #include. Dashboard side (services/security-dashboard.sh): write_voicemail()/ _apply_voicemail_flag() toggle the flag and a PIN (generated once, kept across future toggles), regenerate_voicemail_conf() keeps voicemail.conf's [default] section in sync, and a module reload takes effect without a full Asterisk restart. Extensions tab gets a Voicemail column next to Messaging, showing the PIN once generated. New Voicemail tab lists every mailbox's messages (parsed from Asterisk's own msgNNNN.txt sidecars) with an inline <audio> player per row — /voicemail/audio validates ext/msg against strict regexes plus a resolved-path containment check before ever opening a file. Dashboard gets read-only ACL + systemd ReadOnlyPaths access to the voicemail spool dir, and a new sudoers-scoped module-reload command. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
3584ad6499 |
Make backup pruning fully automatic, no prompt
The safety net (only ever touches disposable *.backup.* files, never the newest one for any given file) makes this low-stakes enough to just set up unprompted, the same way tab completion already is — matches the user's own read on it. Still fully idempotent (skipped if the timer already exists), so a rerun doesn't re-ask or redo anything. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
c50704e1b3 |
Add automatic tab-completion setup and old config-backup pruning
Two things surfaced from actual use this session: 1. Tab completion (tools/setup-completion.bash, added earlier) required manually editing ~/.bashrc — easy to skip or get wrong (confirmed live: the source line never actually landed the first time). base now wires it in automatically (idempotent, checked by grep first), matching how it already touches ~/.bashrc for SSH Host aliases. 2. No pruning existed anywhere for the *.backup.<timestamp> files ~60 different services create before overwriting a live config (Caddyfile, /etc/fstab, etc) — every one of them backs up, none clean up, so they accumulate forever on a box reconfigured regularly. tools/prune-old-backups.sh prunes by file mtime (not by parsing the timestamp out of the filename — robust to the %Y%m%d-%H%M%S vs %Y%m%d_%H%M%S inconsistency across services), always keeping the single newest backup per distinct file regardless of age. Verified both the normal case (mixed old/new, prunes only the old ones) and the edge case (every backup for a file is old, keeps the newest one anyway) against real fixtures. base offers it as a daily systemd timer (prompted, since it deletes files — unlike the tab-completion wiring, which doesn't). Also added logrotate for Caddy's own access logs (/var/log/caddy/*.log), which had no rotation at all and grow unbounded on an active box. Uses copytruncate specifically: the log directory is bind-mounted into the running Caddy container and read live by CrowdSec, so truncating in place avoids either of them needing to notice or react to a rotation happening. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
cd003dbaf3 |
Add Gatus auto-sync from Caddyfile, promote ensure_yq to lib/common.sh
Adds one Gatus endpoint per Caddy site block automatically, tagged group: caddy-sync — the sync only ever adds/removes entries in that exact group, so anything added by hand (the default external checks, a custom endpoint) is never touched regardless of what the Caddyfile looks like. Offered at install time (syncs once immediately) and, if systemd is available, scheduled via a timer every 15 minutes so a site added or removed later gets picked up without re-running the installer — matches the "schedule that checks the Caddyfile" shape asked for. Domain extraction tracks actual brace depth (reusing the same approach as remove_service's Caddy block removal) rather than a naive line-by-line scan, so it correctly skips the global options block and parenthesized snippet definitions like (authelia) without needing to special-case them by name. Verified end-to-end against a real Caddyfile/config.yaml fixture with the actual mikefarah/yq binary: initial sync adds the right entries and leaves the default "external" group alone, a second run with no Caddyfile changes is a true no-op (0 added, 0 removed), and changing the Caddyfile (removing one site, adding another) correctly adds the new endpoint and removes only the stale one. Also fixes a real gap surfaced while building this: ensure_yq (used by both gatus.sh now and onlyoffice.sh already) checked `command -v yq` alone, which a box can satisfy with a completely different, incompatible yq — confirmed live in this environment, Debian/Ubuntu's own `yq` apt package is kislyuk/yq (a Python jq-wrapper) which silently errors on mikefarah/yq's `e '.path' file` syntax every caller here depends on. Now checks the version string actually identifies as mikefarah's before trusting it, installing to /usr/local/bin (which precedes /usr/bin on Ubuntu's default PATH) if not. Promoted ensure_yq itself from onlyoffice.sh (its only previous user) to lib/common.sh now that gatus.sh needs the same thing, so both share one implementation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
7c85f326c8 |
Accept Beszel's own "copy for docker compose" snippet directly
Confirmed live: Beszel's Settings -> Tokens & Fingerprints page surfaces
a "copy for docker compose" shortcut as the prominent way to grab the
key/token — not a bare string — so the previous two-prompt flow (paste
plain key, paste plain token) didn't match what people actually have
in their clipboard. The user pasted that YAML snippet into .env by
hand afterward, using the container's raw KEY/TOKEN names and YAML
`NAME: 'value'` syntax instead of what the compose file's own
${AGENT_KEY:-}/${AGENT_TOKEN:-} substitution actually reads — the
agent then failed with "no key provided" since AGENT_KEY was never
actually set.
_beszel_extract_field pulls KEY/TOKEN out of whatever shape the paste
arrives in — YAML mapping (`KEY: 'value'`), compose list style
(`- KEY=value`), or plain `KEY=value` — regardless of quoting. The
agent prompt now accepts a multi-line paste (the whole snippet, or
just the two lines) instead of asking for two separately pre-extracted
values; if no labeled KEY/TOKEN line is found at all, it falls back to
treating the paste as a bare key and asks for the token separately, so
a Beszel version that really does just show plain strings still works.
Verified all three paths directly: the exact mixed KEY:/TOKEN= paste
the user had, the skip path (blank first line leaves .env untouched),
and the bare-value fallback with no labels at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
|
||
|
|
e10e1e90b4 |
Fix invalid docker-compose.yml when Beszel is installed with local Caddy
Confirmed live: "networks.beszel-agent additional properties ... not allowed" — the root-level networks: block (_CADDY_NET_SECTION) was placed between the two services instead of after both. Since it sits at 0 indentation, YAML parsed the following beszel-agent: line as a continuation of the networks: mapping instead of a new services: entry, so the whole beszel-agent service definition got swallowed as if it were a (invalid) child of networks.caddy_net. gatus.sh's identical _CADDY_NET_BLOCK/_CADDY_NET_SECTION pattern never hit this because it only ever has one service, so the same placement is always the last content in the file there. Moved _CADDY_NET_SECTION (the root-level networks: definition) to after both services; _CADDY_NET_BLOCK (the per-service "join caddy_net" snippet) stays right after the hub's own volumes, where it correctly nests under the beszel: service only. Verified by regenerating the compose file with local Caddy present and parsing it with PyYAML: services.beszel and services.beszel-agent are now proper siblings, beszel-agent keeps its image/environment/volumes keys, beszel's own networks: is scoped to just that service, and the root networks: definition is separate and correctly placed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
7938399e99 |
Add Beszel for lightweight server + Docker monitoring
Answers "what's the best way to see CPU/RAM/disk usage on this box"
(IONOS's own dashboard doesn't expose it) and "does Gatus cover this" —
it doesn't, Gatus is a black-box HTTP check (is the site responding
from the outside), Beszel is white-box host/process monitoring (is the
box under memory/disk pressure, is a container actually running vs.
crash-looping). Complements Gatus rather than replacing it.
Mirrors the hub+agent same-system layout from beszel's own
supplemental/docker/same-system/docker-compose.yml (fetched from the
actual upstream repo, not reconstructed from memory) — hub is the web
dashboard, agent reads /var/run/docker.sock (read-only) to report every
currently-running container automatically, no per-service config
needed as containers get added or removed.
Genuinely a two-phase install: the hub's SSH keypair and universal
token only exist after logging into its web UI once, so this starts
the hub, walks through where to find both values, and finishes wiring
the agent once provided — skipping is fine, a rerun in "update" mode
detects the agent was never connected and offers to finish it.
Verified the generated docker-compose.yml/.env by running the actual
file-writing code path with docker/configure_caddy_for_service mocked
out — confirmed TOKEN/KEY are correctly left as literal
${AGENT_TOKEN:-}/${AGENT_KEY:-} for Docker Compose's own substitution
at "up" time, not prematurely expanded by the heredoc itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
|
||
|
|
ad011b2db8 |
Add check_container_health helper, wired into mattermost.sh as reference
A service's "Started" message after docker compose up -d doesn't mean the app is actually working — it can still crash-loop (bad DB password, missing required env var, etc.) with no visible sign until someone separately runs docker ps -a much later, exactly what happened repeatedly this session (mattermost, koha-db, homebox, vaultwarden, filebrowser all showed a clean "Started" message while crash-looping). check_container_health (lib/common.sh) waits briefly, checks the container's actual status and restart count via docker inspect, and prints recent logs automatically if it's not running or has already restarted — instead of a misleading one-line success message. Wired into mattermost.sh's own start step as the reference implementation, guarded by declare -F so standalone runs (no lib/common.sh sourced) degrade gracefully. Not retrofitted across every other service in one pass — this establishes the shared helper so other services can adopt it incrementally. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
74628f3736 |
Fix vaultwarden SMTP false-positive and mattermost DB password mismatch
vaultwarden: SMTP_PORT defaulted to "587" and SMTP_SECURITY was a
hardcoded "starttls" literal in the .env template, written
unconditionally regardless of whether SMTP_HOST was ever provided.
Confirmed live: skipping SMTP entirely (blank SMTP_HOST) still wrote
real values for those two, and Vaultwarden reads that as "some SMTP
config is present," refusing to start ("Both SMTP_HOST and SMTP_FROM
need to be set") even with host/from genuinely blank. Both now stay
empty unless SMTP_HOST is actually set.
mattermost: DB_PASS/MM_SECRET were only reused from the existing .env
when MODE=update — a "fresh" reinstall always generated a new
POSTGRES_PASSWORD. Confirmed live: choosing fresh after removing only
the mattermost app container (not the whole directory) regenerates the
password in .env while db/'s existing Postgres data still enforces the
OLD one from its first init (the entrypoint skips re-init on existing
data), causing "password authentication failed for user mattermost" on
every start. Whether db/ already has real data is what actually
determines whether the old password is still live, not which reinstall
mode was chosen — reuse the existing secrets whenever db/ is non-empty,
regardless of MODE.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
|
||
|
|
c177312947 |
Fix three crash-looping services: koha-db, homebox, vaultwarden
koha-db: compose used MYSQL_ROOT_PASSWORD/MYSQL_DATABASE/MYSQL_USER/
MYSQL_PASSWORD, but this mariadb:11 image version's entrypoint doesn't
recognize MYSQL_ROOT_PASSWORD as any of its accepted root-password
options at all. Confirmed live: "Database is uninitialized and password
option is not specified" on every start, even though DB_ROOT_PASS was
correctly generated and present in .env the whole time. Switched all
four to their MARIADB_* equivalents.
homebox: a newer homebox release requires HBOX_AUTH_API_KEY_PEPPER (at
least 32 bytes) or the container panics on startup — this installer
never set it. Generate one with generate_password 48 and wire it
through .env + the compose environment block.
vaultwarden: the SMTP setup prompts let you enter a host but leave
"SMTP from address" blank (no default), writing a half-configured state
Vaultwarden refuses to start with ("Both SMTP_HOST and SMTP_FROM need
to be set"). Validate after prompting — if SMTP_HOST is set but
SMTP_FROM came back empty, disable SMTP entirely instead of writing a
config known to crash the container.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
|
||
|
|
77440c8f75 |
Live-scan Traccar's device-protocol range for collisions, not just Asterisk's ports
The 5000-5150 range was only ever checked against Asterisk's hardcoded fixed ports (5038/5060/5061) for a first instance — no live scan of the rest of the range, because the directory-count-based offset mechanism only triggers for an explicit additional instance. Confirmed live: this range sat unclaimed at the OS level while this Traccar instance's container had never actually started, so an unrelated service's own find_free_port scan found port 5007 genuinely free (nothing was listening there yet) and took it — invisible to any check until Traccar itself tried to bind its declared range for the first time, failing with "port is already allocated". Add a live scan across the whole intended range (skipping Asterisk's expected carve-outs at the base 5000-5150 range) and shift by 1000, same step the multi-instance path already uses, until genuinely clear. Also fixed the compose-block and README generation, which keyed off INSTANCE_SUFFIX being empty to decide whether Asterisk's exclusions were needed — now keyed off whether the range is still the unshifted default (PROTO_MIN -eq 5000), since a first instance can now end up shifted too. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
8bdf4c0a07 |
Add explicit UFW rules for Caddy's 80/443 — Docker was silently bypassing UFW
services/caddy.sh never called ufw allow for any of its published ports. Confirmed live: ufw status showed no rule for 80 or 443 on a box with UFW active (default deny incoming), yet HTTPS sites were reachable fine — Docker manipulates iptables directly for published container ports (the ports: mapping in Caddy's own compose file), which bypasses UFW's filtering entirely regardless of what ufw status reports. This wasn't an actual exposure gap — 80/443 are supposed to be open to everyone, that's the point of a reverse proxy — but it means ufw status was actively misrepresenting this box's real firewall state on its two most externally-facing ports, which is exactly the kind of thing that looks like a problem (and did, when investigating an unrelated Let's-Encrypt failure) even though nothing was actually unprotected. Add explicit ufw allow rules for 80/tcp, 443/tcp, and 443/udp (HTTP/3) so ufw status reflects reality, matching every other service in this repo managing its own firewall rules instead of relying on undocumented Docker/iptables interaction. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
060f288107 |
Fix authelia.sh skipping the real Caddy snippet due to a commented example
grep -q "(authelia)" matches caddy.sh's starter Caddyfile's own commented-
out example block ("# (authelia) {", included as documentation), so
authelia.sh believed the real snippet already existed and never wrote
it. Any later service adding `import authelia` to its own site block
then references a snippet that only exists as a comment.
Confirmed live: this takes Caddy down completely, not just the
Authelia-protected site — "Error: adapting config using caddyfile:
File to import not found: authelia" is a load-time failure, so Caddy
restart-loops and every site it fronts goes with it.
Anchor the check to an actual uncommented snippet definition
(^\(authelia\)\s*\{) instead of a bare substring match.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
|
||
|
|
6588e3abf1 |
Add a way to remove an existing vpn-data-mount without re-adding it
Removal only existed as a side effect of picking the same share again in the "fully redo this mount" path — there was no direct way to just remove a mount you no longer want, without walking back through host/ share selection first. Adds a top-level "Remove any existing VPN data mounts?" prompt that lists every configured mount by number (via the new _vdm_list_all_mounts) and lets you remove one or more, reusing the existing _vdm_remove_mount teardown (decrypt-layer unit, unmount, credentials file, tagged /etc/fstab entry). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
39e7b2ae6e |
Fix Mattermost crash-looping with permission denied on config.json
The official mattermost/mattermost-team-edition image runs as a fixed UID/GID 2000 baked into the image — it does not read PUID/PGID env vars, that's a LinuxServer.io s6-overlay convention this image doesn't use. This file set them anyway (computed from ACTUAL_USER's uid/gid), which did nothing, while the actual host directories (./data, ./logs, ./config, ./plugins) got chowned to ACTUAL_USER instead of 2000:2000. Confirmed live: the container fails on its very first start with "could not create config file: open /mattermost/config/config.json: permission denied" and crash-loops — which then presents as a 502 from Caddy, an easy trail to follow to the wrong place since Caddy itself was fine. Removed the dead PUID/PGID mechanism and chown the app's own volumes to 2000:2000 after the existing ACTUAL_USER chown. db (postgres:15-alpine) isn't affected — its entrypoint fixes its own volume ownership on startup. Runs on both fresh installs and "update" reruns, so re-running the installer on an already-broken instance self-heals it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
cfd3b04b7b |
Add a full-redo option for an already-mounted vpn-data-mount share
The previous fix only let an already-mounted share reconfigure its decrypt layer — there was still no way to change the mount point or re-enter credentials for a share that's already set up, since the label prompt was skipped entirely in that path. Add a real choice when an existing mount is found: reconfigure the decrypt layer in place (as before), fully redo the mount (tears down the old one via the new _vdm_remove_mount and falls through to the normal fresh-mount flow, label pre-filled from the old one), or skip. _vdm_remove_mount stops/removes any decrypt-layer systemd unit first (it sits on top of the CIFS mount), then unmounts, removes the credentials file, and removes the /etc/fstab tag+entry via a fixed ",+1d" range — the tag line plus exactly the one mount line that always immediately follows it, not an open-ended range to the next blank line or EOF (the class of bug fixed earlier in this file's history for the now-removed remote smb.conf-writing code). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
304e4b644c |
Let an already-mounted share be reconfigured instead of blocking on label reuse
Re-running vpn-data-mount for a share that's already mounted hit the label-uniqueness check with no way through it — picking the same share always re-prompted for a label, and the existing label was always already taken by definition, so it just looped rejecting every input. Confirmed live: reported as an infinite "Label 'data1' is already used" loop right after this share had already been mounted in an earlier run. Detect the existing fstab tag for the same host+share up front and reconfigure it in place — currently the one thing safe to redo without touching a working plain mount: the gocryptfs decrypt layer added previously. Skips the label prompt and remount entirely for a share that's already set up. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
a23d5d6fc7 |
Add optional client-side encryption layer for vpn-data-mount
The VPS side of a plain SMB mount necessarily sees plaintext while it's mounted and in use — that's unavoidable for data a VPS service actually needs to read. What's avoidable is everything else: a disk image, backup, or provider-side look at the VPS while the mount isn't actively in use showing your actual files instead of ciphertext. tools/gocryptfs-setup-home.sh (new): standalone tool for the home box. Creates a gocryptfs-encrypted directory and passphrase file; the user points their existing Samba share's `path =` at the cipherdir (manual step — same read-only stance on remote Samba config vpn-data-mount.sh already takes, this tool doesn't touch smb.conf either). services/vpn-data-mount.sh: after mounting a share over CIFS as before, optionally offers a gocryptfs decrypt layer on top. Fetches the passphrase fresh over the same SSH trust already used for share discovery, pipes it straight into gocryptfs, and never writes it to the VPS's own disk. A generated systemd unit (via a wrapper script, not one long quoted ExecStart= one-liner — avoids stacking systemd's own word-splitting on top of bash -c's) keeps the decrypted view coming back on boot, re-fetching the passphrase each time rather than caching it. Fully opt-in and per-share — a plain unencrypted mount works exactly as before if declined. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
9e886ff9a7 |
Fix CIFS mount error(79) caused by missing nls_utf8 kernel module
The keyutils fix alone didn't resolve it — confirmed live with keyutils already installed, the same error persisted. Root cause: the hardcoded iocharset=utf8 mount option requires the kernel's nls_utf8 module, which some kernels don't ship at all (confirmed live: `modprobe nls_utf8` on a stock Ubuntu 6.8.0-137-generic VPS kernel returns "FATAL: Module nls_utf8 not found" — not loadable, not built in). Every such mount fails with errno 79 (ELIBACC) regardless of credentials, which is why this recurred identically after the keyutils fix. Both vpn-data-mount.sh and mount-network-drive.sh now probe with a harmless `modprobe nls_utf8` before adding the option, and mount without it (falling back to the kernel's build-time nls_default) with a clear warning if the module isn't available, instead of hard-failing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn |
||
|
|
2d9501a56c |
Fix CIFS mount error(79) caused by missing keyutils package
Errno 79 is ELIBACC ("Can not access a needed shared library"), not
ENOKEY as previously assumed — mount.cifs prints glibc's literal
strerror() text for it. It recurred with valid, correctly-captured
credentials because the real cause was never authentication: cifs-utils
hard-depends on the libkeyutils1 library but only Recommends the
keyutils package itself, which ships /sbin/request-key and the
/etc/request-key.d/*.conf handlers the kernel's upcall path invokes.
Minimal cloud VPS images commonly disable install-recommends, so
`apt-get install cifs-utils` alone silently skips it and every mount —
guest or fully credentialed — fails identically.
Install keyutils explicitly wherever cifs-utils is installed:
services/base.sh's unconditional package list, vpn-data-mount.sh's
lazy install-on-mount path, and tools/mount-network-drive.sh's SMB
branch.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
|
||
|
|
1a259e0895 |
Fix password prompt silently stripping leading/trailing whitespace
Reported live: mount error(79) again despite already switching to real credentials + sec=ntlmssp — this time with a Samba password containing special characters. Root cause confirmed directly: `read -r -s pw1` without `IFS=` silently strips leading/trailing whitespace even when reading into a single variable (verified: " P@ss word! " -> "P@ss word!", 10 chars instead of 12). A password with a leading/trailing space — common from a password manager's copy-paste, or a stray keystroke — got quietly trimmed on the way into the credentials file, so it no longer matched what was actually set on the Samba account. That mismatch surfaces as this same cryptic ENOKEY mount error, not an obvious "wrong password". Fixed with IFS= on both reads. Also echo the captured length (never the password itself) right after entry, so a silently-stripped character is something you can catch and cross-check yourself before the mount even attempts, instead of only after it fails. |
||
|
|
dd6f1d0a5d |
Make vpn-data-mount strictly read-only on the remote Samba config
Per direct request: never write to the home box's smb.conf at all, not
even carefully — just discover what's already shared there and mount it.
Removes all remote provisioning (installing Samba, creating/removing
share blocks, resetting smbpasswd accounts) entirely, which also removes
the whole class of bug the previous two fixes were patching around
(destructive section-removal, clobbering another mount's saved password) —
a tool that can't write can't repeat that kind of damage.
New flow: resolve/name the host and bootstrap SSH trust as before, then
read-only list every real share already in the home box's smb.conf
(skipping [global]/[homes]/[printers]/[print$]) via a plain SSH `cat`,
falling back to a sudo'd read only if that comes back empty — still only
ever reading. Presents them as a numbered list and accepts a flexible
selection ('1', '1,3', '1-3', '1 3 5', or combinations), asks once for the
Samba username/password to connect with (reusing a previously-saved
password for the same user+host if one exists), then mounts each picked
share locally over CIFS with its own /etc/fstab entry — same as before.
Verified the selection parser against all the documented formats plus a
mixed comma+range case and garbage/empty input.
|
||
|
|
a2d3b0a651 |
Fix smb.conf section removal deleting everything after the target share
Reported live: Samba broke on the home box after this ran. Root cause confirmed by reproducing it directly: the old removal step used `sed -i "/^\[share\]$/,/^$/d"` — a range delete from the share's header through the next BLANK line. A home box whose smb.conf has no blank line separating sections (common — nothing requires one) means that range never finds a terminator and sed deletes straight through to end of file, taking every share defined after the target one down with it. Reproduced against a 4-section smb.conf with no blank lines: the old approach left only [global] standing, silently destroying two unrelated, pre-existing shares that had nothing to do with this tool. Replaced with an awk pass that removes lines from the target share's own [header] up to the next `[section]` header or EOF — the actual boundary of an INI-style section, independent of blank-line formatting. Also now builds the new config in a scratch file and validates it with `testparm` before it's ever copied over the live smb.conf; on validation failure it leaves the existing file untouched and exits instead of restarting smbd against a config that might not even parse. The existing smb.conf.backup.<timestamp> step (already present before this fix) is what the user is recovering the home box with in the meantime. Verified the fix against the exact reproduction: the same 4-section, no-blank-line smb.conf now retains all three untouched sections after removing only the target one. |
||
|
|
9e5a84f4f5 |
Don't blindly overwrite existing Samba config; stop clobbering shared account passwords
Reported live: the tool unconditionally reconfigured Samba even though "Samba already installed on the home box" was already correctly detected — that check only ever covered whether the smbd package exists, never whether a share for the requested path (or the Samba account itself) was already set up. Two real problems, not just a UX one: 1. Every run appended/replaced a [share] block and reset the target account's password unconditionally, even against a share the user had already configured by hand. 2. Since the Samba account is the SSH username (shared across every mount from the same home box), setting up a SECOND mount from the same box would silently reset the account's password — breaking the FIRST mount's already-saved credentials file with no warning. Now: checks the remote smb.conf for an existing share exporting the exact requested path first (via a plain SSH+awk query) and offers to reuse it as-is (prompting for its real credentials, since a Samba password is stored hashed and can't be read back) instead of overwriting it. If creating a new share, checks whether this tool already set a password for the same user+host pair (from another mount) and reuses it instead of resetting the account; if the account exists with an unknown password (set up some other way), asks rather than silently clobbering it. _vdm_find_remote_share/_vdm_find_existing_smb_password/_vdm_prompt_password are all called via command substitution by their caller, so none of them call log_info/log_warning/etc. internally — those all write to stdout in this codebase, which would corrupt the captured value. Verified the awk share-lookup and the fstab-tag password lookup against sample data. |
||
|
|
abd1bcd35d |
Fix wordpress.sh picking an already-occupied port
Reported live: a fresh site's container failed to start with "address already in use" on its assigned port. wordpress.sh scanned for a free port by grepping `docker ps -a`'s port list — that only reflects ports Docker itself currently has bound, so it's blind to ports held by non-Docker processes or anything Docker isn't reporting cleanly at that instant. Every other service in this repo scans with find_free_port (checks actual OS-level listening sockets via ss) per CLAUDE.md's "Port collision avoidance" section; wordpress.sh was the one holdout still using its own weaker check. Switched to the shared helper, already available in this file's own standalone stub and via lib/common.sh — no new dependency, just using what was already sitting there unused. |
||
|
|
28252b97d3 |
Fix vpn-data-mount's guest-mount errno 79 bug, add per-share SMB accounts,
host naming, and chain-in from filebrowser/audiobookshelf/emby Reported live: "mount error(79): Can not access a needed shared library" on the local CIFS mount step. That message is misleadingly worded — errno 79 is ENOKEY, not a real missing-library problem, and a plain `guest` mount with no explicit `sec=` hitting it against a real Samba server is a known cifs-utils/kernel-cifs rough edge in the anonymous-session keyring path. Fixed as a side effect of switching away from guest access per direct request (real per-share Samba accounts, not root/guest, matching "user accounts for data directories"): each mount now gets a dedicated Samba account (reusing the SSH username — that Unix account already exists on the home box) with a generated password, remotely provisioned via smbpasswd over the same SSH trust, and mounted locally via a root-only credentials file (same convention tools/mount-network-drive.sh already uses) plus an explicit sec=ntlmssp instead of guest. Host naming: entering a raw IP now offers to name it in /etc/hosts, then uses that name for everything from then on (SSH commands, the CIFS mount address, and re-runs against the same IP). Deliberately /etc/hosts, not ~/.ssh/config — an SSH Host alias only helps the `ssh` command resolve a name, mount.cifs never consults ~/.ssh/config at all, so an alias alone wouldn't get the actual mount using a name. Still offers to also add a matching SSH Host alias on top (pure convenience — skips typing the username for interactive ssh use) when services/ssh-config.sh's helpers are available. Chain-in: filebrowser/audiobookshelf/emby now offer to run vpn-data-mount first if their data is on a home box that isn't mounted yet, and default their own directory prompt to whatever was just mounted (VDM_LAST_MOUNT_POINT, explicitly unset before each chain call so an unrelated earlier vpn-data-mount run in the same setup.sh session can't leak its mount point in as a stale default). |
||
|
|
8c5be53950 |
Extract SSH key import out of base.sh into a standalone, re-runnable service
Was only ever runnable once, buried inside base.sh's required-setup flow — no way to re-run just this step for a box that already went through base setup but needs another admin's key added later, or (the immediate case) a home box for services/vpn-data-mount.sh that only needs this one step. services/ssh-key-import.sh holds the real logic now (GitHub/Launchpad import via ssh-import-id, optional password-auth lockdown); base.sh's _base_setup_ssh chains into it the same way services/asterisk.sh chains into security-dashboard/pstn-trunk, with a degraded (no import, just ensures the SSH server itself is running) fallback for a pure standalone `sudo bash base.sh` run with no sibling files sourced. Independently runnable via `sudo ./setup.sh ssh-key-import` or `sudo bash services/ssh-key-import.sh`, and shows up in the whiptail menu under extras alongside ssh-config. Marked as never showing [installed] in is_installed()/install_count(), same as ssh-config — it's a repeatable management action, not a thing with an install state. |
||
|
|
0e42de1cda |
Add vpn-data-mount: SMB mount from a NetBird-connected home box
Offered right after NetBird setup during required/base setup, matching the requested flow (base packages -> NetBird -> data mount). Repeatable by design rather than a one-shot step, since different services can have data on different home boxes — asks for a home box IP every time and can be run again for additional boxes/shares. Flow: test for existing passwordless SSH first (covers "both boxes already share a key via GitHub import, or any other means" for free — if it already works, nothing else runs). If not, generate an SSH keypair and offer ssh-copy-id or a manual/GitHub-import fallback (ssh-import-id, the same mechanism base.sh's own SSH setup already uses) — needed because a home box that took base.sh's "disable password login" option won't accept ssh-copy-id at all. Once passwordless SSH works, use it to remotely install and configure Samba on the home box for a chosen path, then mount it locally over CIFS with a tagged /etc/fstab entry. SMB over NFS/SSHFS per this session's direction: not a "huge" speed gap for normal use, and SSHFS's own encryption is redundant overhead once the VPN tunnel already encrypts everything. Guest-accessible (no separate Samba credentials) since the VPN is the real access control — only NetBird-connected peers can reach the home box's NetBird IP at all. Also: - cifs-utils added to base.sh's always-installed packages, same reasoning as Docker/Compose being unconditional there instead of installed lazily on first mount. - is_installed()/install_count() in setup.sh gained a vpn-data-mount case (state lives in tagged /etc/fstab entries, not $DOCKER_DIR, since this isn't a Docker service) — mirrors wordpress's "count real instances" handling rather than a flat 0/1. - Every SSH call in the new service explicitly runs as $ACTUAL_USER (sudo -u), not root — the script itself runs as root throughout, but the SSH key lives in $ACTUAL_HOME/.ssh, so a bare `ssh` call would silently use root's own ~/.ssh instead and never find it. Caught by review before this shipped, not after. - UNATTENDED mode skips outright with a message instead of spinning forever on prompt_text's always-blank default under --unattended, since none of this flow's prompts (home box IP, remote path, ...) have a sane non-interactive default. |
||
|
|
3a3833596c |
Fix filebrowser crash-looping on permission denied opening its database
Confirmed from gtstef/filebrowser's own Dockerfile (_docker/Dockerfile): the image runs as a fixed non-root user (adduser -u 1000 filebrowser; USER filebrowser), not root and not remappable via PUID/PGID. The installer's broad `chown -R $ACTUAL_USER:$ACTUAL_USER "$FB_DIR"` left the bind-mounted ./data owned by $ACTUAL_USER (root, on a box where the installer itself runs as root) — UID 1000 inside the container then had no write access to it, so every start failed with "could not open database: open /home/filebrowser/data/database.db: permission denied" and the container crash-looped indefinitely (restart: unless-stopped kept retrying every ~60s, matching the log timestamps this was diagnosed from). Re-chown ./data to 1000:1000 specifically, after the broad chown so it isn't clobbered back to $ACTUAL_USER. |
||
|
|
11a4e249b6 |
Add missing cancel option to 15 more multi-instance services; add setup.sh --status
Same bug as the previous filebrowser/fmd fix: vaultwarden, immich, audiobookshelf, homebox, rustdesk, emby, meshcentral, traccar, lyrion, actualbudget, mealie, joplin, jellyfin, unifi, and ntfy all showed "Manage that install (update / full reinstall / cancel)" when re-run against an existing install, but choosing "1) Manage" fell straight through into the same unconditional fresh-install flow every time regardless of choice — no way to actually cancel or update in place. Wired all 15 up to prompt_reinstall_mode, matching the reference pattern in services/mattermost.sh: update pulls + restarts the existing container without touching config, cancel leaves the install untouched, fresh falls through to the existing full-install flow unchanged. Also add `setup.sh --status`: a plain-text listing of every service with its install state, using the exact same is_installed() calls the whiptail checklist's [installed] marker uses. Exists so "is X actually installed" can be answered by reading terminal output directly, without depending on a whiptail checklist screen where a narrow/resized terminal can truncate the "[installed]" suffix off-screen with no visible sign that happened. |
||
|
|
4123662571 |
Fix fmd's broken Docker image and add missing cancel option to two installers
fmd.sh pointed at nulide/findmydevice, which no longer exists on Docker
Hub — the project has moved twice (nulide/findmydevice ->
gitlab.com/Nulide/findmydeviceserver -> gitlab.com/fmd-foss/fmd-server) and
was rewritten from Node.js to Go+React along the way, confirmed against the
current upstream repo and its GitLab container registry. This means the
service never actually started for anyone who installed it before this fix
("pull access denied for nulide/findmydevice, repository does not exist").
Switch to registry.gitlab.com/fmd-foss/fmd-server:0 (GitLab's own registry
has no "latest" tag; ":0" tracks the current major release the same way
this repo's other services use a floating tag). The old FMD_ADMIN_PASSWORD
model is gone from the app too — replaced with FMD_REGISTRATIONTOKEN
(self-registration gated by a token instead of one shared admin login), and
the database path moved from /fmd/data to /var/lib/fmd-server/db.
Also: filebrowser.sh and fmd.sh both showed "Manage that install (update /
full reinstall / cancel)" when re-run against an existing install, but
choosing "1) Manage" fell straight through into the same unconditional
fresh-install flow every time — no way to actually cancel or update in
place, contradicting both the banner text and the documented
prompt_reinstall_mode contract (CLAUDE.md's "Update vs. fresh reinstall on
rerun"). Wired both up to prompt_reinstall_mode, matching the reference
pattern in services/mattermost.sh. The same gap exists in 15 other
multi-instance services (vaultwarden, immich, audiobookshelf, homebox,
rustdesk, emby, meshcentral, traccar, lyrion, actualbudget, mealie, joplin,
jellyfin, unifi, ntfy) — not fixed here, flagged for a follow-up pass.
|
||
|
|
5aef55a059 |
Merge pull request #270 from outis1one/claude/vps-capacity-assessment-r57vw3
Claude/vps capacity assessment r57vw3 |
||
|
|
9e06ed4b83 |
Bake cross-service port collision avoidance into every service script
With 70+ services sharing a handful of common default ports (emby and jellyfin both default to 8096, changedetection and frigate both default to 5000, arm and nextcloud both default to 8080...), nothing previously checked whether a service's default port was actually free on the host. Whichever service installed second would silently write a compose file claiming an already-held port, only failing at `docker compose up` time. Adds two shared helpers to lib/common.sh: - port_in_use PORT [PROTO] — true if something's already listening - find_free_port VARNAME START [PROTO] — scans upward, writes back the first free port Every service that publishes a fixed host port now scans before writing docker-compose.yml, on every install (not just when adding an explicit additional instance). On a normal single-install host this is a silent no-op; it only changes behavior when something else already holds the port. - The 19 services already given multi-instance support this session had their port scan moved out of the "add instance" branch to run unconditionally, since the same collision risk exists on a plain first install. - 20 more services with previously-hardcoded ports gained scanning for the first time: archivebox, arm, calibre-web, changedetection, drum-rhythm-game, gatus, n8n, nextcloud, onlyoffice, stirling-pdf, uptimekuma, portainer, iopaint (both GPU/CPU compose branches), koha (paired), syncthing (paired), wg-easy (paired, plus WG_PORT env so generated peer configs keep the right Endpoint), homeassistant (bridge-mode only — host mode can only warn), frigate and frigate-audio (multi-port stacks, moved together). - caddy.sh is the deliberate exception: 80/443 stay fixed and only warn on collision, since silently moving Caddy itself would leave nothing listening where any client actually looks. - authelia.sh needs no change — it has no published host port at all. - Every service's standalone bootstrap fallback (sudo bash services/x.sh with no sibling files) got the same two helpers duplicated into its stub block, matching how every other shared helper is already handled there. Documents the full pattern in CLAUDE.md's new "Port collision avoidance" section, including the quoted-heredoc/backtick-escaping gotcha and the network_mode:host limitation (can only scan ports the app takes as a configurable env var). Verified via bash -n on every changed file, plus functional runs seeding occupied ports for each collision shape used here (single, paired, multi-port stacks) and confirming the scan/shift and generated compose/README output are correct — including the emby/jellyfin, nextcloud/arm, and frigate/changedetection collision scenarios that originally motivated this. |
||
|
|
b860a8b174 |
Add multi-instance support to 13 more services
Retrofits the standard multi-instance pattern (documented in CLAUDE.md) onto actualbudget, filebrowser, fmd, homebox, immich, jellyfin, joplin, lyrion, meshcentral, ntfy, rustdesk, unifi, and vaultwarden. First instance of each keeps its original name/paths/ports unchanged; adding a second instance prompts for a short name and auto-scans for free ports. Service-specific handling beyond the base pattern: - joplin, immich, unifi: dedicated Postgres/Mongo container per instance (not shared), matching the backup-isolation reasoning in CLAUDE.md. - meshcentral, unifi: multiple fixed ports scanned/shifted together so they stay paired per instance. - rustdesk: 6-port block shifted by a fixed offset per instance, since the image hardcodes its internal ports with no per-port env override. - jellyfin: DLNA/discovery UDP ports only published for the first instance to avoid a host-wide fixed-port conflict. - lyrion: first instance keeps network_mode: host (required for Chromecast/Squeezebox broadcast discovery); additional instances fall back to bridge networking with auto-scanned ports, trading away zero-config discovery since a second container can't also bind host networking's fixed ports. - magicmirror.sh already had its own working multi-instance pattern (upfront instance count, numbered subdirs) and was left as-is. Verified via bash -n on every changed file, plus scripted functional runs (fake docker/ss) exercising first + second instance installs for every port-scanning shape used here (single, dual-paired, quad-paired, block-offset) and confirming dedicated per-instance DB naming and the lyrion host->bridge compose output. |
||
|
|
a9a7200cb5 |
Merge pull request #269 from outis1one/claude/vps-capacity-assessment-r57vw3
Claude/vps capacity assessment r57vw3 |
||
|
|
d5d979ac31 |
Add multi-instance support to audiobookshelf, emby, mealie, traccar
Same pattern already established by services/mattermost.sh and services/wordpress.sh: first instance keeps the plain name/paths/ ports exactly as before (zero behavior change for anyone with a single instance already installed), and only choosing to add a second introduces suffixed naming with its own directory, containers, and ports. - audiobookshelf.sh, emby.sh, mealie.sh: straightforward -- suffixed dir/container name, auto-scanned free host port(s) via `ss`, Caddy subdomain default suffixed to avoid collision. emby.sh's existing music-only mode is untouched, just correctly parameterized. - traccar.sh: the harder one -- has its own dedicated Postgres container, an autoheal container, and a 150-port device-protocol range that can't be scanned port-by-port. Additional instances shift the whole range by 1000 (6000-6150, 7000-7150, ...) based on how many traccar/traccar-* directories already exist, which never lands on Asterisk's fixed ports the way the first instance's range does, so no exclusions are needed there. Also scoped the autoheal label per-instance (autoheal-traccar-<suffix>) -- autoheal watches by Docker label host-wide, not scoped to a compose project, so two instances sharing the generic "autoheal" label would each try to manage the other's container too. Found and fixed two real bugs via testing before committing, not just code review: - The device-protocol range offset counted existing instances via `find $DOCKER_DIR -maxdepth 1 -name 'traccar*'`, which also matches $DOCKER_DIR itself if its own basename happens to start with "traccar" (true in my test harness, structurally possible in real use too) -- fixed with -mindepth 1. - Verified port auto-scanning actually detects a simulated in-use port and increments past it, using a stateful fake `ss` rather than trusting the logic by inspection alone. Verified end-to-end for all four: first instance unchanged from prior behavior, second instance gets fully distinct dir/containers/ports, and (traccar specifically) correct DB container, correctly-scoped autoheal label, and correct shifted port range in the generated compose file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug |
||
|
|
5f36b14f93 |
mattermost: add PikaPods migration helper (DB dump + files import)
New opt-in prompt on fresh/new installs (skipped on "update" reruns, where an existing instance is already in real use and importing over it would be destructive): "Migrating from an existing Mattermost instance (e.g. PikaPods)?" -- if yes, generates migrate-from-pikapods.sh in the instance's own directory, same generated-helper pattern as Immich's import-photos.sh. Checked PikaPods' own docs before writing this rather than guessing at their export mechanics: they expose per-pod SFTP (file access) and a Database-access toggle that hands you an Adminer link for a full SQL dump -- their own documented backup/migration flow is stop the pod, SFTP the files, export the DB via Adminer. The generated script assumes that shape (plain-text SQL dump + a files directory) and says so in its header, including that PikaPods' exact SFTP layout wasn't verified against a live pod so the files-argument path needs the user's own confirmation. What the script does: stops the mattermost container (leaves the DB container running), drops and recreates the database owned by the same existing role -- so .env's credentials are never touched or regenerated, avoiding the "restored data, mismatched password" bug class fixed elsewhere in this repo -- imports the dump via psql, rsyncs the files directory into ./data, restarts. Requires typing "YES" to proceed since it's destructive to whatever's currently in the fresh instance's database. Correctly parameterized per-instance: pulled from install_mattermost's own MM_CONTAINER/DB_CONTAINER variables, so it's already correct for either the first instance or an additional named one. Verified end-to-end: prompt fires correctly at the right point in the flow, generated script is syntactically valid, and the container names/paths it's parameterized with match the actual instance being installed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug |
||
|
|
a5d57050b3 |
wordpress: switch to dedicated MariaDB per site (was shared)
Reconsidered after the shared-MariaDB design's real cost became clear: Kopia's generic backup (services/backup.sh) stops a service's container to snapshot it, so a shared MariaDB instance would back up -- and would have to be restored -- as one unit covering every site at once. Restoring just one site's database to an earlier point meant restoring the whole shared snapshot to a temporary location first and manually extracting that site's data back out, not a direct restore. Each site now gets its own dedicated MariaDB container embedded in its own docker-compose.yml (same pattern as services/nextcloud.sh) instead of registering a database on a shared instance: - Removed _wordpress_ensure_shared_db() and the wordpress-db/ wordpress_net shared resources entirely. - Each site's compose file gets a `db` service (container <site>-db) on an explicitly-named per-site default network (<site>_net), so wp-cli's one-off container reliably joins the right network without depending on Docker Compose's implicit naming convention. - DB creation goes through the mariadb image's own MYSQL_DATABASE/ MYSQL_USER/MYSQL_PASSWORD env vars on first boot (same as nextcloud.sh) instead of an imperative `docker exec mysql -e "CREATE DATABASE..."` against a shared container. - Root and site DB passwords are both reused across reruns (read from the existing .env), verified via a real update-mode rerun. Tradeoff, stated in both the script's header comment and the generated per-site README: more RAM per site (~100-150MB for a full MariaDB container instead of a slice of one shared instance) in exchange for independent backup/restore. Data was already fully isolated either way (separate database + user, always required since WordPress's schema uses generic table names) -- the shared-vs-dedicated choice was only ever about the container/process, not the data. Re-verified end-to-end against the fake docker shim: distinct ports, distinct dedicated DB containers/networks per site, correct compose/ .env structure, credentials preserved across an update-mode rerun. docs/vps-sizing-recommendations.md: updated to match -- WordPress capacity recomputed for dedicated-per-site MariaDB (~580MB headroom at 4 sites, ~976MB at 2, vs. the shared design's ~700MB/~950MB). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug |
||
|
|
a26d1831ee |
Add services/wordpress.sh — multi-site WordPress with shared MariaDB
New service: self-hosted WordPress, sized for running several independent sites the way a hosting company would, not just one blog. - Multi-site from the start: every site requires a name (no unnamed "first instance" special case like mattermost's — there's no backward-compat reason to special-case one here) and gets its own directory/container/port, but all sites share ONE MariaDB container (chain-installed on first site, reused by every other one) instead of a dedicated database container per site — same resource-sharing idea as services/coturn.sh, just scoped to WordPress's own sites rather than shared across different services. Each site gets its own database + user within that shared instance. - E-commerce is just WooCommerce, a normal WordPress plugin — no separate infrastructure. PHP memory_limit/upload_max_filesize/ post_max_size are pre-tuned (256M/64M/64M) so a product-catalog import doesn't hit default-image limits on the first try. - wp-cli (official wordpress:cli image, run as a one-off container sharing the site's html volume) does the initial WordPress core install non-interactively — title, admin account — so there's no browser setup wizard to remember per site. Falls back to printing the exact manual command if the site wasn't ready in time. - Auto-scans for a free host port per site (multiple sites can't all bind 8090), matching the "auto-scanned free ports for extras" idea already used by mattermost's multi-instance support. - DB and admin passwords are reused across reruns (checked against the DB-password-regeneration bug class already fixed elsewhere in this repo, e.g. PR #265) — verified via a real update-mode rerun that the credential doesn't change. - setup.sh: is_installed() gets a wordpress case — every site is named from the first one on, so there's never a plain $DOCKER_DIR/wordpress directory the default case could match against. - README.md: added to the utilities services table + copiable list per CLAUDE.md's three-step rule for new services. Also fixed `coturn` being in the homelab row's prose but missing from the copiable list block below it — a pre-existing gap from when coturn.sh was merged. Verified end-to-end via non-interactive dry runs against a fake docker shim (no live daemon in this environment): 3 sites installed in sequence get 3 distinct databases, 3 distinct auto-scanned ports, the shared DB is only set up once, and an update-mode rerun preserves the existing DB password rather than regenerating it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug |
||
|
|
22a6366258 |
coturn: fix unescaped backticks corrupting generated README + stray error
The "Adding a new service that needs TURN" example in coturn.sh's write_readme heredoc had one unescaped backtick pair (`sudo ./setup.sh coturn`) while every other backtick in the same heredoc was correctly escaped. Since write_readme's heredoc is unquoted (intentionally, so $DIR-style interpolation works elsewhere in the file), bash treated it as a command substitution: it actually tried to execute `sudo ./setup.sh coturn` at install time, printed "sudo: ./setup.sh: command not found" to the terminal on every coturn install, and silently dropped the intended text from the generated README. Found while verifying the shared-coturn multi-consumer flow end-to-end (coturn install -> asterisk + 2 mattermost instances all registering concurrently) — confirmed working correctly otherwise: three distinct credential files, no collisions, all three referencing the same host/ port, and reruns correctly reuse the cached credential instead of regenerating. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug |
||
|
|
d2848cecc3 |
immich: add native S3 storage engine support for thumbnails/uploads
Adds an opt-in prompt to store Immich-managed data (thumbnails, encoded video, new uploads) in S3-compatible object storage instead of local disk, using Immich's native IMMICH_STORAGE_ENGINE=s3 — deliberately NOT a FUSE-mounted bucket. Checked this against real reported issues before implementing: Immich uses symlinks internally that S3 doesn't support under FUSE (ENOSYS errors), and its startup does thousands of stat()/ read() calls that FUSE-over-network handles badly enough to crash the mount under latency spikes as small as 100ms. Native S3 mode talks to the bucket over the S3 API directly, sidestepping both problems. Independent of the existing external-library strategy — an external library (existing photos indexed read-only, e.g. over a VPN mount) is a separate mount either way and works the same regardless of where Immich's own managed data lives, since S3 mode only replaces UPLOAD_LOCATION. - New prompts: bucket, region, endpoint (for non-AWS S3-compatible providers — auto-sets S3_FORCE_PATH_STYLE when given), prefix, access key ID, and secret key (read via `read -rs` so it doesn't echo; left blank with a warning under UNATTENDED, since there's no sane default). - Refactored the docker-compose.yml generation from two near-duplicate heredocs (with/without external library) into one with composable volume-line variables, to avoid quadrupling the duplication once S3 was added as a second axis. - Skips creating local upload-location subdirectories entirely in S3 mode (thumbs/upload/backups/library/profile/encoded-video) — Immich manages that structure inside the bucket itself. - .env now gets chmod 600 (previously ungated) — more pointed now that it can hold an S3 secret key, not just the DB password. - Generated README documents the S3 setup and carries the FUSE-mount warning forward so a future reader doesn't try that route instead. Verified both the non-S3 baseline (unchanged output) and S3 mode end-to-end via non-interactive dry runs — correct .env, correct compose volumes, no local upload dirs created, 0600 permissions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug |
||
|
|
ad38b96cfe |
Make swapfile a default for every install, not just Asterisk droplets
Extracts the swapfile logic out of services/asterisk.sh (previously DigitalOcean-droplet-gated) into lib/common.sh's ensure_swapfile() — provider detection was never really the point, the actual condition that matters is "modest RAM, no swap yet," which applies just as much to a non-DO VPS running several Docker services at once as it did to a single-purpose droplet. - lib/common.sh: new ensure_swapfile(), same fallocate/mkswap/fstab/ swappiness logic as before, threshold raised from 2048MB to 4096MB (a 4GB box running a full service stack is exactly the case that motivated this change — the old threshold would have skipped it). - services/base.sh: calls it unconditionally so every install gets the same check regardless of which other services get chosen. - services/asterisk.sh: swapfile call is no longer gated behind IS_DO — calls the shared helper directly. Kept a standalone-mode stub (same pattern as this file's other stubbed helpers) so `sudo bash asterisk.sh` with no base.sh in the picture still gets it. Idempotent either way: a box that already has swap, or already got it from base.sh earlier in the same run, no-ops immediately. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug |
||
|
|
cdc1e48d82 |
Merge pull request #268 from outis1one/claude/vps-capacity-assessment-r57vw3
Claude/vps capacity assessment r57vw3 |
||
|
|
7a66ef2e2f |
emby: add music-only setup mode with per-user library access guidance
Prompts whether this install is music-only (changes the default folder to ~/music and the prompt wording — Emby has no compose/env flag for "music-only", library types are chosen in its own web setup wizard, so this is guidance plus a sane default, not a functional restriction). Generated README walks through adding only a Music library and, the actual reason to pick Emby for this role over Lyrion, per-user library access under Dashboard → Users → Access — LMS/Lyrion has no equivalent, just one shared server-wide password. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug |