Three apps were instanceable and the rest were silent, so the feature
looked far narrower than it is. Every app has now been checked against
the two rules instance_create.sh enforces, and the answer recorded in
its config rather than left unset.
32 apps are instance-safe and now say so. Six are not, and each says why
in its own words instead of being indistinguishable from an app nobody
had reviewed:
pihole a DNS server must own port 53
unbound a resolver must own its fixed 5335
stalwart a mail server must own 25/465/587/993
traefik must own 443, and one Traefik routes every other app
prometheus node-exporter and cadvisor carry no "prometheus" prefix
stoat pins 7881, and database/redis/rabbit/minio carry no prefix
The first four are genuinely one-per-host: the port is not arbitrary, it
is the protocol. The last two are compose-identity problems and could be
fixed by prefixing those service names, which is a change to make
deliberately rather than in passing.
Recorded as an explicit false with a reason, not left unset, so the next
person reads a decision instead of an absence. The audit was verified not
to pass anything vacuously: every app resolves at least one service name,
so no app reached "eligible" merely because nothing was found to check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Ports page showed no service selected for Stoat, and the app's URL
never appeared anywhere, so there was no way to learn which port it is
on.
Field 1 of a PORT descriptor is the parent SERVICE:
db_list_installed_app matches it against each compose service name to
attach the app's access URL. Stoat named containers instead —
"stoat-caddy" and "stoat-livekit", whose services are "caddy" and
"livekit". It is the only app in the catalogue that did; rocketchat says
rocketchat-service, matrix says matrix-synapse, and both are real
service names. Nothing matched, so no URL was ever associated and the
Ports page had nothing to select.
The port itself was always allocated correctly — Caddy publishes 4210 —
which is why the app worked for anyone who already knew the number.
The live config had also lost the 11th field (the Traefik subdomain,
"stoat") that the template carries, so restore that at the same time.
Not a Caddy problem: Stoat's Caddy is its internal path router, doing
prefix stripping and Location rewrites across api, events, autumn,
january, gifbox and web plus file-serving /.well-known/stoat. Traefik is
the edge in front of it — the same split Nextcloud uses with its nginx
sidecar — and Traefik cannot serve that .well-known file at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The only two templates in the catalogue shipping UPDATE_TYPE=manual,
and both rationales turn out not to apply to what auto actually does.
Automatic updates act on update_available, which is digest-based: they
apply a REBUILD of the tag an app already tracks and never cross a
version line. Crossing lines is the stepped Upgrade, which is a
deliberate action and stays one.
So Rocket.Chat, pinned to 8.7.0 with mongo 8.0, cannot be walked across
a major by the automatic path — the failure its comment guarded against
was unreachable. And Stoat's nine stoatchat services are all pinned to
the same tag, so a pull moves them together or not at all; they cannot
"roll forward independently" into an API/events mismatch.
What manual did cost was real: neither app picked up security rebuilds
of the version it was already on. Rocket.Chat is carrying a critical CVE
at the time of this change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eleven app configs declared CFG_<APP>_AUTH_PROFILE as a "capability tier for the
WebUI auth tools". Nothing read it — not a shell script, not the frontend, and it
was never emitted into apps.json, so the WebUI could not have acted on it even in
principle.
The job it was meant to do is already done, and done better: authAdapterCanDo
tests `declare -F authAdapter_<app>_<method>`, so what an app can do is derived
from the functions it actually implements. A declared tier is a second source of
truth that can only drift — traefik declared single_password while its adapter
implements setPassword only, and linkding declared nothing at all while shipping
a full multi-user adapter, and neither mismatch had any effect.
Removed the key and its comment from all eleven configs, and replaced the stale
contract note in auth_adapter.sh with what the dispatcher really does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rocket.Chat gets the full five — create, list, reset password, set admin,
deactivate — over its REST API. Two supporting changes make that possible:
- The first admin is now seeded at install from CFG_ROCKETCHAT_ADMIN_*, and
the setup wizard is marked completed. Previously the install left a wizard
for someone to click through, and, more to the point, left no account for
the tools to authenticate as. Rocket.Chat honours those env vars only while
no admin exists, so they are inert on every later boot.
- Calls go out with curl from the host rather than from inside the container.
The image ships node but no curl, and the base URL is read from the deployed
compose's ROOT_URL, which the APP_URL tag has already resolved to whatever
this install actually serves on.
Stoat gets three — list, disable, enable — and the adapter says plainly why it
stops there. Password reset would mean reimplementing its argon2 hashing in
bash, where being subtly wrong writes a hash nothing can verify and locks the
account out with no error at the time. "Make admin" would misrepresent the
model: Stoat's permissions are per-server bitfields on server_members, not a
global flag. Its service containers are distroless with no shell and it has no
admin CLI, so the database is the only durable handle.
Deactivate rather than delete in both, and the destructive actions refuse to
touch the account the tools authenticate as.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both apps demanded a domain and Traefik. That was over-constrained: LibrePortal
ships WireGuard, Headscale and private ports, so LAN and VPN-only is a
first-class deployment here, and Rocket.Chat and Mattermost already prove chat
apps work fine on http://<lan-ip>:<port>.
The gate on Matrix rested on a mistake of mine: server_name being permanent.
server_name and public_baseurl are independent — the identity can be a domain
you own with no DNS behind it while clients reach the server on a LAN address,
so federation can be switched on later by adding DNS and TLS, with no rebuild
and no lost history. CFG_MATRIX_SERVER_NAME now exposes exactly that, and the
install warns when it falls back to the machine's IP.
What is genuinely lost without a domain is stated where it belongs, at install:
Matrix cannot federate and Element's mobile apps want HTTPS; Stoat cannot do
camera or microphone, because browsers gate getUserMedia on a secure context
and a VPN does not change that, the check being on the URL scheme.
Both now derive their URL from the port that was actually allocated. Since ports
are only assigned during compose-up, each writes a best guess before start and
corrects it afterwards, restarting only when the value really changed.
Three bugs found while proving it works end to end:
- The Synapse image writes /data as its UID/GID env, default 991, which under
rootless is a host sub-UID owning nothing — so the generated signing key could
not be moved by the install user. Both the generate container and the service
now run as the same identity USER_TAG resolves to.
- Element's config.json is bind-mounted as a file, and docker silently creates a
DIRECTORY when the source is missing. An early return left exactly that
landmine, which then broke every later run. It is written first now, and a
stale directory is cleared.
- A successful admin registration was reported as an error: checkSuccess read $?
after an intervening [[ ]] test rather than the command's own status.
Verified with no domain and no Traefik installed: Synapse answers
/_matrix/client/versions and /health on http://<ip>:<port>, admin login returns
a token, and Element is configured against the corrected base_url.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nine LONG_DESCRIPTION values had drifted well past the 90-140 char
range the rest of the catalog uses (stoat was 407). Cut them back
while keeping the caveats that matter — Rocket.Chat's user cap,
Stoat's resource weight, Matrix federation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Installing rocketchat failed with
invalid IPv4 address: ParseAddr("IP_DATA_2"): unable to parse IP
ipUpdateComposeTags allocates one IP per SERVICE_TAG_N annotation and fills
IP_TAG_i only where SERVICE_TAG_i exists. The four new apps tagged only their
primary service, so every sidecar — matrix's postgres, mattermost's postgres,
rocketchat's mongo, and fifteen of stoat's sixteen — kept a literal IP_DATA_n
in the deployed compose and docker refused to create the container.
Tag every service that carries an ipv4_address, index-aligned with its IP_TAG.
For stoat that also meant moving caddy from SERVICE_TAG_1 to _6 so the indices
line up with the IPs rather than the reading order.
mastodon had the same latent break (IP_TAG_2 and _3 untagged) and is fixed the
same way — it would have failed on first install for the same reason.
SERVICE_TAG carries the compose *key*, not container_name: 'libreportal app
restart <app> <service>' passes it to 'docker compose restart', which only
understands keys.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five apps (mastodon, owncloud, mattermost, matrix, stoat) took their generated
secrets from the compose-side generator tags PASSWORD_TAG_<n>/RANDOM_TAG_<n>/
HEX_TAG_<n>/VAPID_TAG_<n>. Those mint a fresh secret on every templating run, so
a reinstall handed the app a new database password while its data volume kept the
one initdb was given, and the app came back up unable to open its own database.
Move them to <app>.config as RANDOMIZED* placeholders, reaching the compose via
the #LIBREPORTAL|<APP>_<KEY>_TAG| mechanism tags_processor_app_config_values
already provides. No new handler: the tag name is derived from the config key, so
this is a config line plus a tag per secret. Generation is unchanged — still
random on first install; the value is now remembered instead of re-rolled.
Also fixes two things this exposed:
- The RANDOMIZED* replacers matched unanchored. `sort -u` orders slots lexically
(1, 10, 11, 2), so slot 1's pattern rewrote the prefix inside slot 10's
placeholder and slots 10+ ended up holding slot 1's secret with a digit glued
on — derivable, and invisible because the values weren't byte-identical.
Anchoring with \b makes match order irrelevant. Verified at 20 slots across
all four placeholder types: 64 keys, 64 distinct values, no prefix collisions.
- generateRandomPassword drew from base64 without constraining the mix; measured
over 2000 draws, 1 in 40 contained no digit at all. Retry until the result has
both a digit and a letter, bounded so a pathological length can't spin.
owncloud gains a fix in passing: its compose seeded the admin account from
PASSWORD_TAG_2 while the WebUI displayed CFG_OWNCLOUD_ADMIN_PASSWORD, which was
generated separately and never used. Both now read the same value.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sixteen containers: MongoDB, Valkey, RabbitMQ, MinIO and eleven Stoat services.
Servers, channels, roles and voice/video through LiveKit — the nearest thing in
the catalogue to Discord itself, at the price of being much the heaviest app in
it. Does not federate.
The compose service keys are deliberately kept identical to upstream's
(database, redis, api, autumn, ...) while container_name is prefixed stoat-.
Compose registers both on the network, so upstream's internal defaults keep
resolving and LibrePortal still gets the prefixed names its port, firewall and
backup layers key on.
Upstream's Caddy is kept as the internal path router and Traefik simply proxies
to it, which is upstream's own supported behind-a-reverse-proxy mode —
reimplementing eight path routes as Traefik labels would be a second copy to
keep in sync for nothing. The install hook is a non-interactive port of
generate_config.sh, and it never rewrites an existing secrets.env:
REVOLT__FILES__ENCRYPTION_KEY decrypts every file ever uploaded, so
regenerating it would orphan the whole media store.
LiveKit's UDP media range is published literally rather than through the port
table, because the firewall rebuild emits /tcp rules only and a range declared
there would produce a wrong rule rather than no rule. Voice falls back to TCP
7881 until the range is opened by hand; the post-install notice says so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>