docs/pstn-sms-test-checklist.md's manual steps (registration, trunk
reachability, dialplan contexts, kill-switch state, usage-alert timer
health, recent call/message activity) are all things a script can check
directly instead of re-typed by hand each time — and re-typing them is
exactly what led to the container-name mistake in the prior commit.
tools/pstn-test-check.sh auto-detects the container/directory the same
way the checklist doc now does, runs every automatable check, and prints
PASS/WARN/FAIL per item plus a summary. What it can't cover — actually
placing a call or sending a text — still needs the checklist doc.
Caught during testing against real command output pasted in this
session: the endpoint-parsing loop matched pjsip's own column-header
line ("<Endpoint/CID...> <State...>") as if it were a real endpoint row,
producing a bogus result. Fixed by skipping any row whose parsed
extension starts with "<".
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
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
./setup.sh mat<TAB> now completes to ./setup.sh mattermost, same for
flags. Service names are read fresh from services/*.sh on every
completion — never a hardcoded list, which would go stale the moment
a new service file gets added (matches this repo's own "adding a
service = adding one file, nothing generated" rule from CLAUDE.md).
Verified live: sourced the script and confirmed completions for "mat"
and "--li", and specifically confirmed "bes" resolves to "beszel" —
the service added earlier this same session — with zero changes
needed to the completion script itself, proving the list is genuinely
dynamic rather than something that looked right once and then rotted.
Self-locating via its own BASH_SOURCE path rather than a hardcoded
install directory, so it keeps working regardless of where the repo
is cloned. Works through a leading `sudo` via bash-completion's
standard sudo pass-through (enabled by default on Ubuntu).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
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
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
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
Content-hash based dedup (size -> partial SHA-256 -> full SHA-256), not
filename matching, so identically-named files with different content are
never confused for duplicates and differently-named files with identical
bytes always are. Stdlib-only Python so it runs unmodified on Windows
10/11 and Debian-flavored Linux (Ubuntu, Mint). Supports photo/music/
video/docs extension categories or custom extensions, JSON/CSV reports,
and optional delete/move/hardlink cleanup actions that default to a dry
run and require --yes to actually touch files.
dest/current/ stays a plain mirror; deleted/overwritten files are moved to
dest/versions/YYYY-MM-DD/ so accidental deletions are recoverable while
intentional --delete still propagates. Unchanged files in version folders
are hardlinked via --link-dest to avoid extra disk cost. Supports local and
remote (SSH) source/destination paths, saved jobs, and a cron hint.
https://claude.ai/code/session_015SmW4EAD6mMLZGVCygy3GR
Previously only edited config.yaml. Now:
- Shows existing volume mounts with host→container paths
- Detects when a host path isn't mounted yet and adds it to compose
- Remove flow optionally removes the volume mount from compose too
- Show displays both compose mounts and config sources side by side
- Uses down/up (not just restart) when compose changes
https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt
Previously only detected the first volume mount, so sources on a
second mount (e.g. /files2) were not reachable. Now lists all mounts
from docker-compose.yml and lets the user pick by number or type a
full container path directly.
https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt
Allows editing share path, mount point, and options for an existing
fstab entry. CIFS entries also offer credential update (backs up the
old credentials file before overwriting). Backs up fstab before any
change and offers immediate remount to apply.
https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt
Interactive script to mount network drives and persist them in /etc/fstab:
- SMB/CIFS: prompts for share path, mount point, credentials (saved to
/etc/samba/credentials.<name> at 600/root), SMB version
- NFS: prompts for export path, mount point, NFS version
- Tests the mount before writing fstab
- Backs up /etc/fstab before any edit
- Remove flow: unmounts, removes fstab entry, optionally removes creds file
- Shows currently mounted network shares and fstab entries
- Uses nofail + _netdev so a missing share doesn't block boot
https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt
(( n++ )) returns exit code 1 when the pre-increment value is 0,
which kills the script under set -euo pipefail. Replace with
n=$(( n + 1 )) which always returns 0.
https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt
yq stops mid-array when it encounters inline comments like
'# grant per-user only'. Strip comments via sed before piping
to yq in list_sources, source_exists, and before in-place edits.
https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt
Sources added manually without a name or config block showed "null"
for name and readOnly. Use yq // operator to fall back to sensible
defaults: "(unnamed)" for missing name, false for missing booleans.
https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt
Assumes the parent data directory is already broadly mounted in
docker-compose.yml so subdirectories are already inside the container.
Script now only edits config.yaml and restarts the container — no
docker-compose surgery needed per new source.
https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt
Interactive script that keeps docker-compose.yml and config.yaml in sync
when adding or removing file sources in a FileBrowser Quantum installation.
- Prompts for host path, source name, defaultEnabled, readOnly
- Validates against duplicate volume mounts and source entries
- Backs up both files before editing
- Uses yq v4 for safe YAML editing (no fragile sed/awk on YAML)
- Offers container restart after changes
- Works standalone; no post-install framework dependency
https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt
Root cause: user scopes inside /data/... (the main data bind-mount) mean
any extra mount would nest inside another bind-mount — Docker does not
reliably layer these, so directories appeared in `ls` but were empty
and FileBrowser returned 404.
Fix: detect nested scopes and migrate them to the named volume (fb_users)
before adding any extra mounts.
Migration flow:
- Detects scope is inside /data bind-mount
- Suggests a new scope path in the named volume (e.g. /alice)
- Creates the scope directory via docker exec (no host-side clutter)
- Offers to mount the user's personal files dir as my-files/
- Updates the user's scope in FileBrowser via API PUT /api/users/:id
- Then adds requested extra dirs as non-nested bind-mounts
Also added: let user choose the display name for each added folder
(e.g. mount audiobookshelf but show it as "audiobooks").
https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt
When a user's FileBrowser directory lives inside an existing bind-mount
(e.g. /srv/data/users/alice), Docker needs an empty directory at the
host-side equivalent path before it can overlay an inner bind-mount on
top of the outer one. Without it the inner mount silently fails and
the extra folder never appears.
Now creates the mount-point directory on the host automatically before
adding the compose entry, with a visible note so the user knows a dir
was created.
https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt
Symlinks don't work for giving scoped FileBrowser users access to extra
folders — FileBrowser's afero.BasePathFs blocks symlinks that resolve
outside the user's scope directory.
Switch to bind-mount approach: additional_directories.sh edits
docker-compose.yml to add real bind-mount entries for each extra folder,
so FileBrowser sees them as actual subdirectories within the user's root.
No symlinks, no scope-boundary issues.
Features:
- Reads FB_PATH from .env to list available source folders on the host
- Parses docker-compose.yml to show what's already configured per user
- Adds/removes volume entries with a timestamped backup before each edit
- Prompts to restart the container after changes
- Normalises scope paths from the API (handles missing leading slash)
manage_users.sh removed — user CRUD is handled by the FileBrowser web UI.
https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt
Removed all user CRUD (add, delete, rename, passwd, scope change).
The FileBrowser web UI handles those. Script is now focused solely
on adding/removing extra folder shortcuts for users who have a
restricted root directory. Simpler menu, ~half the code.
https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt
- Remove silent mkdir -p when adding extra directories. If the user's
directory doesn't exist in the container, ask before creating it.
This avoids creating folders the admin didn't intend.
- Rename all user-facing "scope" text to "directory" throughout:
prompts, banners, menu labels, column headers, help text, error
messages. The FileBrowser API field is still called "scope"
internally, and the CLI subcommand name stays "scope" for compat.
https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt
docker-compose now uses two separate mounts:
fb_users named volume → /srv (user home dirs + shortcuts, Docker only)
FB_PATH bind mount → /srv/data (actual files, unchanged on host)
User dirs and their shortcuts live entirely in the Docker named volume —
they persist across reboots but never appear on the host filesystem.
Full-access scope is /data; per-user scopes are /alice etc.
manage_users.sh:
- Detect layout: get_data_root() returns /srv/data (new) or /srv (legacy)
so the script works with both old and new installs automatically
- "Linked directories" renamed to "additional directories" throughout
(menu labels, prompts, error messages, usage text)
- prompt_add_links → prompt_add_dirs, menu_links → menu_add_dirs
- Available-folder listing and symlink targets use get_data_root()
- Scope examples updated to show /data for full access (new layout)
- Don't offer additional directories when scope is /data (full access)
https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt
Removed dateFormat, hideDotfiles, singleClick, and sorting from the
POST /api/users payload — FileBrowser rejects them as invalid data
types on some versions. Only stable fields are now sent.
https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt
- User creation was silently failing: api_post used curl -sf (fail on
HTTP error) with output piped to /dev/null, so set -Eeuo pipefail
would exit the script with no message. Now captures HTTP status code
and response body, printing the server's error message on failure.
- Symlink folder listing (? prompt) only searched -type d, missing
symlinked directories in /srv. Changed to -type d -o -type l so
all browsable entries appear. Also switched xargs echo to tr for
a cleaner one-line display.
- mkdir -p and ln -s in docker exec were not checked for errors;
failures would silently kill the script under set -e. Both now
show a useful error message and continue/return instead of crashing.
https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt
Three fixes in ensure_token:
- Use jq to build the login JSON so special chars in passwords
(quotes, backslashes, etc.) don't break the raw string interpolation
- Add || true to the curl call so set -e doesn't silently exit on
connection refused before the response check runs
- Show FileBrowser's actual response on failure so the user can see
whether it's wrong credentials vs unreachable vs something else
https://claude.ai/code/session_01UZus2Q9gNTfUdqSMrhuX29
List top-level directories from /srv so the user knows what to type
without having to guess. Subdirs (e.g. documents/shared) still work.
Skip link prompt when scope is / (user already has full access).
https://claude.ai/code/session_01UZus2Q9gNTfUdqSMrhuX29
Users think in FileBrowser folder names, not container paths.
- prompt_add_links: prompt now "Folder to add [done]:" with examples
like "music photos documents/shared" — /srv added internally
- list_links: strip /srv prefix from displayed target paths
- menu_links: rename options to "Add folders" / "Remove a folder"
- scope prompts: remove the leading "/" hint (confusing); normalise
internally instead
- ok message shows "(from path/subdir)" only when link name differs
https://claude.ai/code/session_01UZus2Q9gNTfUdqSMrhuX29
- prompt_add_links: shared helper loops asking for /srv paths until
blank Enter, creates symlinks via docker exec, skips bad paths
- cmd_add: offers linked-dir prompt right after user creation
- cmd_scope: offers linked-dir prompt after a scope change
- menu_links: tighter submenu (list + add loop + remove) replacing
the old menu_symlinks; called from Modify option 5
- Scope prompts now show the leading / so the user only types the rest
- Note on delete: symlinks on disk survive user deletion (by design)
https://claude.ai/code/session_01UZus2Q9gNTfUdqSMrhuX29
New option 5 in the Modify submenu: "Manage linked directories"
- Lists existing symlinks inside the user's scope dir (via docker exec)
- Add: prompts for source path (/srv/...) and a display name, creates
the symlink inside /srv<scope>/<name> → /srv<source> in the container
- Remove: lists links, prompts for name, refuses to delete non-symlinks
- Warns if source path doesn't exist yet; offers to create anyway
- Auto-detects container name from docker-compose.yml next to the script
- Checks that the container is running before any docker exec calls
This is the recommended workaround for FileBrowser's single-scope
limitation: symlinks inside the scope dir appear as normal folders.
https://claude.ai/code/session_01UZus2Q9gNTfUdqSMrhuX29
- Interactive menu when run with no args (login once, reuse token)
- Modify submenu: change username, password, scope, or toggle admin
- Fix: prompt_password now uses local -n nameref (bash 4.3+) so the
caller's local variable is actually set; printf -v was writing to
global scope and being shadowed by the caller's local declaration
- One-shot commands unchanged: list/add/delete/passwd/scope/rename/info
- FileBrowser only supports one scope per user — documented clearly
https://claude.ai/code/session_01UZus2Q9gNTfUdqSMrhuX29
Undo the host.docker.internal approach from the previous commit — proper
Docker networking routes Caddy to services by container name on the shared
caddy_net, not via the host gateway.
- lib/common.sh: configure_caddy_for_service now accepts either a plain
port number (localhost:PORT fallback) or container:port (preferred).
The Caddyfile entry uses the container name for direct Docker DNS routing.
- services/caddy.sh: remove extra_hosts hack; update Caddyfile template
comments to show container_name:port format
- All service files: update configure_caddy_for_service calls to pass
container_name:internal_port (e.g. "filebrowser:80", "mealie:9000").
Services using network_mode:host keep plain port numbers.
- tools/manage_users.sh: new FileBrowser user-management script (deployed
to ~/docker/filebrowser/ during installation). Manages users via the
FileBrowser REST API: list, add, delete, passwd, scope, info commands.
Documents username format (letters/numbers/hyphens/underscores),
password rules (min 8 chars, letter + number required), and scope path
convention relative to /srv (= FB_PATH on the host).
https://claude.ai/code/session_01UZus2Q9gNTfUdqSMrhuX29