./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
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
./setup.sh filebrowser remove (no dashes) fell through to the normal
install dispatch instead of removing anything, since only the --remove
flag form was recognized. Accept the bare words too — order-independent
either way (./setup.sh remove filebrowser works the same).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
No removal path existed anywhere in this repo — manually removing a
service meant hand-editing docker-compose.yml, the Caddyfile, and UFW
rules yourself, or just leaving orphaned config behind.
remove_service (lib/common.sh) handles the common case: stop/remove
the service's containers (with an explicit y/n on whether to also wipe
data volumes, default no), find and remove its Caddy site block if one
exists, remove any UFW rule tagged with its name, and optionally
delete its ~/docker/<name> directory (default no — keep data as a
safety net unless explicitly confirmed).
The Caddy site block removal (_remove_caddy_site_block) tracks actual
brace depth rather than scanning to the next blank line or EOF — the
same class of bug this repo already hit once with a naive Samba
config edit. Verified against a multi-block test Caddyfile with nested
log{}/header{} blocks: removes exactly the targeted block, leaves
every other block (including ones with their own nested braces)
byte-for-byte intact, and is a safe no-op when nothing matches.
Also fixes the UFW rule-number extraction: ufw status numbered pads
single-digit rule numbers with a leading space ("[ 3]" vs "[10]") to
align columns, which the regex didn't account for — every single-digit
rule would have silently never matched and never gotten deleted.
Wired into setup.sh as a new --remove flag, resolving SERVICE_ALIAS
and validating the name the same way run_service already does.
Scoped to the common case (a Docker service at $DOCKER_DIR/<name> with
a standard configure_caddy_for_service site block); a hand-built Caddy
block or non-standard layout may need manual cleanup for the parts
this can't find. Non-Docker services (base, ssh-key-import, etc.)
report cleanly that they're not handled rather than erroring
confusingly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
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
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
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
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
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
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
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
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
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
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
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
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.
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.
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.
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.
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.
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).
New section covering what it does, the public-vs-private-key security
model (only public keys are ever fetched, no outbound capability like
private-repo access is granted), and how to run it standalone via
sudo ./setup.sh ssh-key-import. Placed ahead of the existing SSH Host
aliases section since that section already references key import as
prior context ("after SSH key import, the wizard offers to add...").
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.
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.
Confirmed (again, by rendering into a captured pty and inspecting the
character grid) that whiptail always renders a blank line between the
instructional text and the checklist box itself, with no parameter to
remove it — so a header "directly above the purple box" isn't achievable
no matter how it's built. Per this session's direction: drop the header
line entirely and just tighten the gap between the count and the service
name (was up to 15 chars of mostly blank space from the wide count field
sized to match the now-removed header label; down to ~5).
Verified end-to-end in the same pty harness: rendered the real dialog,
sent actual keystrokes to toggle two items (one plain, one with a
double-digit count), captured the raw whiptail selection output, and
confirmed the existing "extract text after the last space" logic still
pulls the correct plain service names back out.
Previous commit's leading-space count for the header was an estimate and
visibly off in the follow-up screenshot. Rather than guess again, actually
rendered the dialog into a captured pty (whiptail installed locally,
output fed through pyte to reconstruct the real character grid) and
measured exact column offsets instead of eyeballing.
Root fix: "installed" (9 chars) and "# of installs" (13 chars) are wider
than the underlying data (an "x"-or-blank mark, a 1-2 digit count) — a
narrow data column can never align under a wide label and stay readable,
so it's the data fields that got widened to match the label widths, not
the other way around. Verified alignment holds across installed/
not-installed/double-digit-count rows and at the narrow 78-column width
floor (where the description truncates first now, not the install status —
correct priority, since status is the more critical of the two).
Requested: no checkbox on the header row at all, not just a harmless one.
The previous fake-row header still drew a real [ ] like every other row —
whiptail has no way to suppress that per-row, there's no such thing as a
non-selectable list item in a --checklist.
The instructional text above the list has no checkbox rendering at all
though, since it isn't a list item — moved the header there instead:
"installed" / "# of installs" / "service", spelled out per this session's
request instead of the terse "x"/"#". Spelled-out words can't line up
character-for-character under the 1-2-char data columns below and stay
readable, so the leading spaces are a best-effort approximation, not exact
alignment.
Adjusted the box-height overhead constant (+8 -> +9) since the
instruction text is now two lines instead of one, and dropped the
now-unnecessary sentinel-row filtering from the selection-handling code.
Header row: a first, non-functional checklist entry using the exact same
printf field widths as the real rows ("x # NAME" / "x 1 caddy" / ...),
so it visually reads as column headers for the x/# prefix even though
whiptail has no real header concept. Its sentinel tag ("NAME") is filtered
back out of the selection after the dialog closes, so it's harmless even
if someone checks it and hits <Ok>.
Width: was a flat 78 regardless of the actual terminal, so descriptions
got cut off mid-sentence on anything wider with no way to read the rest
(confirmed from a screenshot — "TURN via the shared coturn s..." trailing
off). Scale with tput cols instead, floored at the old 78 (safe on a plain
80-column terminal) and capped at 160 so a very wide terminal doesn't get
an absurdly wide dialog.
Requested: separate, non-interactive "installed" (x) and "#" (instance
count) columns ahead of the actual selectable checkbox, with the
description no longer carrying any install-status text at all.
whiptail's checklist only has one interactive element per row — the
checkbox — so there's no such thing as a real extra column, tabbable or
not; the tag and item fields are always just inert display text regardless
of what's in them. The closest real equivalent: bake a fixed-width "x"
(installed) + count prefix into the tag field itself. whiptail pads every
row's tag field to the same width, so it lines up visually like columns
even though it's one string underneath. Extract the plain name back out
before dispatch by taking the last whitespace-separated token, since
service names never contain spaces — robust regardless of the exact
prefix width.
Description field is back to plain SERVICE_DESC text now that install
status lives in the tag prefix instead.
"[installed]" was 11 characters of an already-tight 78-column checklist
row, most of the reason the marker had so little room to spare before
whiptail's width truncation silently dropped it (previous commit). "[N]"
says the same thing in 3 characters — and for services that support
CLAUDE.md's multi-instance pattern (a base install plus any number of
"<name>-<suffix>" siblings, e.g. two separate mattermost instances), it's
more informative than a flat "installed": N > 1 means several instances
exist, not just one.
Add install_count() alongside is_installed() in setup.sh: the default case
counts $DOCKER_DIR/<name> plus any $DOCKER_DIR/<name>-* siblings; the
specially-cased services (asterisk, wordpress, etc.) either already count
sites directly (wordpress) or aren't part of the multi-instance pattern, so
they just mirror is_installed() as 0 or 1. Wired into the whiptail
checklist, the non-whiptail plain-text fallback, and --status.
The [installed] text label (previous commit) confirmed working from a
screenshot, but the checkbox itself stays unchecked for installed items by
design — checking it means "install/reinstall this on <Ok>", so
pre-checking every already-installed service would risk a mass reinstall
from just hitting Ok without manually unchecking each one.
Add a second, more immediate cue right next to the checkbox instead:
prefix the item's own tag with "*" when installed (whiptail's checklist
tag is the first column, directly after the checkbox). The "*" is
display-only — stripped back off the selected values before they reach
run_service, so dispatch is unaffected.