It was pinned to the tile's top-right corner, level with the app name, leaving
the status line beneath it visually unbalanced. The tile is two lines, so the
right edge's midpoint is the natural anchor.
The centring has to share `transform` with the existing reveal — the button is
opacity:0 until the tile is hovered and slides in 2px — so the hidden state is
translateY(calc(-50% - 2px)) and the revealed one translateY(-50%), rather than
the previous -2px/0 pair.
Verified under a real pointer, since the button does not exist visually without
one: hovering a tile gives opacity 1 with the tile's and the button's vertical
centres both at 571px.
The button assigned window.location.href, which is a full page load: the
app re-boots, every panel re-fetches and open state is lost — for a
control whose whole job is "show me that task".
It fired more often than it looks. The two branches above it only handle
being ALREADY on an app page with an app name, or ALREADY on /tasks. A
system-wide task carries no app name, and its /tasks/<category>/<id>
target keeps the id in the path rather than a ?task= query, so from the
dashboard or the App Center every guard missed and the reload was the
outcome.
The router handles that URL fine — tasks-manager reads the category and
task id out of the path on mount, so /tasks/all/<id> arrives with the
task already highlighted. Both fallbacks now offer it to spaClean first
and keep location.href only for when the router genuinely is absent.
The URL is normalised to a same-origin path, since navigate() expects a
path and the incoming value may be absolute; anything off-origin is
refused rather than routed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-app backup list borrows the Tasks list's .task-* classes, whose
.task-header ships 4px of vertical padding. That is right for a dense log and
too tight for these rows, which carry a 28px app icon — the content sat hard
against the card edges. And the backup id is the last thing in .task-info with
.task-actions starting immediately after, so the hash and the Restore button
touched.
Both scoped to .backup-snapshot-item so the Tasks page keeps its density —
confirmed after the change: Tasks rows still compute 4px, backup rows 10px.
Four fixes to the Updates tab, all reported from the same screenshot.
Upgrade is green and just says "Upgrade". It was accent-styled and carried the
target version, which duplicated the confirmation it opens — the dialog already
names the version, so the button only had to name the action. Green also stops
it reading as the same control as the cyan Update beside it.
The picker moved from the head of the row to the tail, matching the Tasks list.
It used to lead with an `ov-pick-empty` spacer holding the column open, so with
nothing updatable every row carried a left gutter for a checkbox that was never
coming — the gap in the report. Unselectable rows now render no picker at all.
Select all joins the toolbar, right-aligned above that column, again mirroring
Tasks. It acts on the selectable rows in view, so a narrowing filter chip cannot
leave it quietly picking rows nobody can see.
The confirmation dialog takes an optional icon and the upgrade flow passes the
app's, so it leads with the same icon-in-holder the instance modals use. Styled
in base.css rather than borrowing .app-card-icon: this dialog is global and
appears on pages that never load the apps stylesheet. Its title now uses the
display name too — "Upgrade matrix to…" beside the Matrix logo read as a
different thing.
Verified against the live WebUI: green Upgrade with the gutter gone; Select all
and the trailing checkboxes with two apps temporarily flagged (data restored
byte-identical after); the master toggling 0 -> 2 -> 0; and the dialog showing
matrix.svg under the title "Upgrade Matrix to v1.159.0?".
The button drew its text from --status-danger, which is tuned as a FILL
colour — the confirm button in the remove modal sits white text on top of
it, and there it is correct. Reused as text on the app's own background
it measures 3.79:1 on nebula, 3.84 on dark-blue and 3.95 on the example
theme, all under the 4.5:1 AA floor, which is why the control read as
barely there. Light is the only theme where the same value passes, at
4.53:1, so brightening it globally would have fixed three themes by
breaking the fourth.
Two jobs, two values. --status-danger-text is now defined per theme:
#f87171 on the dark ones (6.29-6.47:1) and #c92a37 on light (5.43:1, up
from 4.53). #f87171 is not a new invention — it is the red the CVE
severity chips already use for exactly this job, so danger-as-text now
looks the same wherever it appears.
The border follows the text so the two stay coherent, and both fall back
to --status-danger, leaving any theme without the token no worse off
than before. The filled modal button is deliberately untouched: white on
#dc3545 was never the problem.
Other danger-as-text call sites exist (loading screen, network notifier)
and would likely benefit, but they render on their own backgrounds
rather than the app surface, so they are not assumed to share the
measurement.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
files_validation.sh exists in the tree but was missing from the generated
source-array, so eager loading never sourced it. Found while diffing the repo
against the live install, where the array had been regenerated in place and
carried the entry the committed one lacked.
Regenerated with generate_arrays.sh rather than copied back from the install, so
the committed array is what the generator actually produces.
Three rows read "CRITICAL · V1.159.0 AVAILABLE" and offered no button, so the
Updates tab looked broken — reported twice as "I can't see an update button".
Nothing was broken: Update is gated on update_available, which is false for all
14 apps here. Those three have newer_version instead, whose action is `upgrade`,
and that button existed only inside the row detail.
Hiding it was justified as wanting more care than a row button. It does not buy
that. updater-page's upgrade() already opens a confirmation spelling out the
per-step recovery snapshot, the rollback-and-stop behaviour and the likely
duration, and nothing runs until it is accepted. So the care is in the dialog
either way; hiding the button only hid the action behind a Details click nobody
had a reason to make.
The row now shows whichever action applies — Update (primary) for a same-tag
image, Upgrade to <version> (secondary) for a new release line — keeping the two
visibly different. The handler was already wired for `upgrade`; only the button
was missing. Update all and the multi-select tickboxes stay gated on
update_available, since walking release lines in bulk is a different risk.
Verified against the live WebUI: Matrix, Rocket.Chat and Mattermost now carry
Upgrade to v1.159.0 / 8.7.1 / 11.10 beside Details.
Gitea Actions is enabled on the repo (1.23.8, has_actions true), and the guard
was run-on-demand until now — which is the same weakness it exists to fix: a
check nobody is obliged to run does not stop a regression landing.
.gitea/workflows rather than .github/workflows: Gitea reads the former first and
the only remote is Gitea.
The image is pinned to node:22-bookworm-slim rather than left to whatever the
runner's label maps to. The guard needs BOTH runtimes — node to run the real
formatter, python3 to drive it — and no stock runner image reliably has the
pair. That image ships node and no python3, so python3 is installed explicitly;
if the base image ever changes, that step fails visibly instead of the script's
shebang reporting a bare "not found".
No `paths` filter. The job is seconds once the image is cached, and a filter
narrow enough to be worth having is also narrow enough to miss the case it was
added for.
Verified by running the workflow's exact steps against a clean clone in that
image: it passes as committed, and injecting an unnamed command
(`libreportal app clone {appName}`) fails the job with the offending command
named and the file to edit.
Worth recording, since it nearly produced a wrong conclusion: scripts/dev is
export-ignore in .gitattributes, so `git archive` omits the guard entirely. A
first test built its fixture that way and the script appeared to be missing.
Checkout is a clone, not an archive, so CI does get it — the export-ignore only
keeps it out of release tarballs, which is what it is for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task titles come from one table whose final fallback returns the raw command
string, so a dispatched command with no matching row does not error — it just
renders as "libreportal instance remove bookstack_work" beside properly named
neighbours. That silence is why this kept being fixed and kept coming back.
The guard reads BOTH files as source — the command templates from
task-commands.js and the pattern table from tasks-format.js — so it fails on a
command added without a name rather than leaving it to be noticed in the UI.
Two checks, both from source rather than guessed from rendered text:
1. Nothing falls through: a title equal to its command, or still starting with
"libreportal ", means the raw fallback was reached.
2. Every `libreportal app <verb>` verb has an actionMap entry. Without one the
generic branch composes "<Verb> Application", which is how "Up Application"
and "Down Application" shipped.
The second check reads the actionMap keys instead of pattern-matching the title,
which a first attempt did and which was wrong: "Reload Application" is both a
correct hand-written label and what the generic branch emits, so the rendered
text cannot distinguish them and the heuristic failed a title that was fine.
Verified by breaking it deliberately in both directions — adding a command with
no pattern, and deleting an actionMap verb. Each is caught, named, and pointed at
the file to edit; both files were restored byte-identical afterwards.
Lives in scripts/dev, which .gitattributes marks export-ignore, so it never ships
in a release tarball. Needs a node and borrows the running container's when the
host has none, the same constraint lp-shot works around for chromium.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task titles come from one declarative table in formatCommandForUser, whose final
fallback is the raw command string. Five of the sixteen command shapes the WebUI
dispatches had no row and hit that fallback, so they showed as
"libreportal instance remove bookstack_work" where a backup shows as
"Bookstack - Create Backup". Cross-checked every shape in task-commands.js
against the real formatter rather than fixing only the reported one:
instance create -> Bookstack - New Instance (work)
instance remove -> Bookstack · work - Remove Instance
system status -> LibrePortal - System Status
system update -> LibrePortal - System Update
system reset -> LibrePortal - Reset System
Instance create is named off the TYPE rather than the new slug: at create time
the instance is not in window.apps yet, so displayName() would fall back to
capitalising the slug and render "Bookstack_work".
Four more were not raw but read badly, because the generic
"<Action> Application" fallback has no entry for the compose verbs: up and down
rendered as "Up Application" and "Down Application". Now Start/Stop Containers,
Reload Application and Check Status. And per-service restart collapsed to
"Restart Application", losing the only thing that distinguished it — it now
names the service.
All seventeen shapes verified through the real formatter: zero fall through.
Instance removal also finishes properly now. It skipped the button lockout a
normal uninstall applies, so the instance's Config/Tools/Backups tabs stayed
live against an app being torn down underneath them; it now makes the same
disableAppButtons call executeUninstall does. And it bounced to the TYPE's
Config page, hiding the very task the click had just started — it now lands on
the removed instance's own Tasks tab.
Verified by driving the WebUI: creating an instance files a task titled
"Bookstack - New Instance (uitest)", removing it lands on
/app/bookstack_uitest/tasks, and disableAppButtons flips all five app tabs from
enabled to disabled. Test instance removed afterwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The snapshot filter's placeholder had no rule at all, so it fell through
to the browser default — a grey chosen for light backgrounds, rendering
near-invisible against a dark panel. "Filter by app, host, or backup id"
is the one hint telling you what the box accepts, so losing it costs the
control its explanation.
Written against --text-rgb rather than a fixed grey so it follows the
theme: white at 60% on nebula and dark-blue, dark at 60% on light, where
a hardcoded #ccc would have been unreadable the other way round. Firefox
dims placeholders on top of the colour, so opacity is pinned to 1 and the
alpha above owns the result. Slightly brighter than the task filter's
0.4, and in line with the shared .form-control rule's effective value,
because this string is instruction rather than decoration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Updating one app and updating everything were both already there — a
per-row Update button and an Update all in the tab header. What was
missing is the middle: choosing three of eight. Both existing controls
are gated on update_available, so with a fully current fleet the tab
shows neither and reads as though it cannot update anything at all,
which is what prompted this.
Checkboxes appear only on rows that actually have something to apply.
Offering one on a current app would be a choice with no outcome, and
"selected 6, updated 2" is a worse answer than not offering the four.
The column is still reserved on every row, hidden — otherwise the icons
shift left and right as scans land and rows change state.
applySelected filters the chosen names against update_available rather
than trusting them: a selection can outlive the scan that justified it,
and asking to update an app with nothing to apply would spend a snapshot
to achieve nothing. Update all now routes through the same function, so
there is one path, one task, and the same per-app contract — snapshot
first, roll back on failure.
Two details that only show up when you use it. The checkbox sits inside
the row head, which is itself the expander target, so its handler has to
claim the click or picking an app also opens its detail panel. And the
count is refreshed in place instead of re-rendering the tab, because a
re-render rebuilds the checkboxes under the pointer mid-selection.
The bar holds its space at zero selected rather than appearing on the
first tick, which would shift the list down under the cursor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The unmaintained warning runs on one field — when upstream last rebuilt
the image — and off-Hub apps had no value for it. Hub answers in a single
call; the OCI API does not expose it at all, so an app on ghcr.io, quay.io
or lscr.io simply could not be assessed for staleness, which is the one
signal a user cannot work out for themselves.
It is in the image, just further down: manifest -> (if a multi-arch
index) a platform manifest -> config blob, whose "created" is the build
time. Three requests instead of Hub's one, once per registry window, and
only for the apps Hub cannot answer for — which is why Hub keeps its
cheap path rather than being routed through this.
Index and single-arch manifests are distinguished explicitly rather than
by position: in an index the first digest is a CHILD manifest, in an
image manifest it is the config itself, so reading "the first digest"
would silently fetch the wrong blob for one of the two shapes.
Live: stoat 2026-08-08, bookstack 2026-08-17, speedtest 2026-08-16,
invidious 2026-08-05 — all previously null. Hub unchanged, navidrome
still answered by the single-call path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`docker ps -f name=<app>` is a SUBSTRING match, and instance slugs are
<type>_<id> — so the base app's name is a prefix of every instance of it.
`name=bookstack` also selected bookstack_home, bookstack_test and their -db
containers, which meant start, stop, restart and remove all silently operated on
every instance of an app instead of the one named.
Worst of the four is remove: `libreportal app remove bookstack` ran `docker rm`
against its instances' containers too. Multi-instance made this reachable — the
naming scheme it introduced is exactly what turns the base name into a prefix.
Each app and instance is already its own compose project, named for its
directory, so the project label addresses exactly the containers belonging to
that app. app_install.sh's own post-install check already used this label; the
lifecycle operations did not.
Found while tracing the IP allocation problem: bookstack_work had vanished, and
checking how uninstall selects containers turned this up. To be clear about
attribution — this bug does NOT explain that disappearance. The log shows an
explicit uninstall of bookstack_work, including its own install folder and log,
which container-level over-matching cannot do. I could not attribute that
removal to a specific command and am not going to guess; the instance has been
recreated.
Verified: with the fix, `libreportal app stop bookstack` stops bookstack and
bookstack-db and leaves bookstack_home and bookstack_test running. Before it,
all six went down. All four Bookstack apps and Stoat serve 200 afterwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Version discovery spoke only hub.docker.com, and every other registry
got a shrug: updaterTagExists returned "no" and updaterRegistryTags
returned nothing. Five apps live off Hub — stoat and wireguard on
ghcr.io, bookstack and speedtest on lscr.io, invidious on quay.io — and
for all of them the updater reported "up to date" having never asked.
That is the same dishonesty as a scan that never ran: an absence of
evidence rendered as a clean bill of health.
There was never a barrier, only unwritten code. The standard
Distribution API needs one extra step: request, read the
WWW-Authenticate challenge, fetch a token from the realm it names,
retry. ghcr.io, quay.io and lscr.io all answer anonymously for public
images — lscr.io by pointing its realm at ghcr.io, quay.io by not
challenging at all.
Docker Hub deliberately keeps its own path. hub.docker.com returns tags
NEWEST-first, so the 100 it pages are the 100 that matter, and it draws
on a different budget from the pull limit — registry-1.docker.io
manifest reads count against the anonymous 100/hour that the updater
needs for actual pulls, and a ladder probes a tag per rung.
Tag LISTING off Hub is a weaker signal and the comment says so: /v2/
tags/list is lexical, not newest-first, and large repos cap the page, so
the newest release can legitimately be absent. Probing backfills it,
which is why the probe fallback added earlier matters more off Hub than
on it.
Verified against all four registries: existence probing correct on eight
cases including true negatives; stoat climbs v0.15.0 -> v0.15.1 through
ghcr.io, and correctly reports nothing above v0.15.1 — the same answer
as before, but now because it looked. Hub unregressed: matrix still
resolves v1.158.0 -> v1.159.0 and nextcloud still ladders 31 -> 32 33 34.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Not a flake. IP allocation lived in the else-branch of "did the database return
any rows for this app", so it ran only when the app held ZERO rows. An app with
even one row skipped the loop entirely, and a service without a row never got an
IP and never would. Its IP_TAG_<n> stayed unfilled, the literal IP_DATA_<n>
reached the compose, and docker refused the app with
invalid IPv4 address: ParseAddr("IP_DATA_3")
which surfaced as "no container started (image pull failed?)". Nothing repaired
it: reinstalling re-ran the same skip, so the app stayed broken until someone
uninstalled it and wiped the rows.
Partial state is not exotic — an app that GAINS a service in a later version hits
this on its very next install, because the old services still hold rows. That is
the case worth worrying about; Stoat only got there by being installed and
uninstalled repeatedly.
Reproduced deterministically by deleting one row from a healthy 16-service Stoat:
the install reported "No IP allocated for service: stoat-rabbit" as a NOTICE,
then "Success: Updated 15 IP tag system", then failed at compose. After the fix
the same broken state self-heals — "Allocated IP: stoat/stoat-rabbit" — with no
uninstall.
Three more bugs in the same path, all found while tracing it:
- ipFindAvailable tested pool membership with a substring match against the
newline-joined list of allocated IPs, so .4 read as taken whenever .46 or .147
existed. Demonstrated: with 3 addresses allocated it excluded 5. Harmless at
low occupancy, but it silently shrinks the pool as it fills and would report
exhaustion early. Now an exact whole-line match.
- ipFindAvailable set available_ip="" on an exhausted pool and carried on to
index the empty array, where RANDOM % 0 is a division-by-zero that would bury
the real message. ipAllocation did the same and still ran its INSERT, writing
a row with an empty resource_value — which then satisfied "this service has an
allocation" forever after, making the service unrepairable. Both now return.
- first_allocated_ip was only assigned inside the allocate branch, so on every
reinstall (where rows already exist) it came out empty and the trusted-domains
list shipped with a hole. Now taken from the mapping.
An unfilled tag is also an error rather than a notice now: the compose is
unshippable at that point, and reporting "Success: updated 15 IP tags" is how
this reached the user as a confusing pull failure several steps later. The
install backstop no longer guesses "(image pull failed?)" either — that guess
was written for one cause and misdirects for every other.
Verified: clean install allocates all 16 with no unfilled tags, a deliberately
broken row self-heals, and no duplicate IPs exist across any app.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The port editor used to serialise ten of the twelve columns, so saving
any port on an app silently discarded that app's Traefik subdomain and
the router fell back to the app-name default. Navidrome lost "music" and
Speedtest lost "speedtest" exactly that way, and nothing reported it —
the app kept working, on the wrong hostname.
The writer is fixed, but an install already carrying the damage would
keep it forever: reconcile preserves the user's value, and a truncated
row IS the user's value as far as it can tell. It now tops such a row up
from the template, appending ONLY the columns the live row does not
reach. Everything the live row states wins — including a deliberately
blanked column — so clearing a subdomain is not undone, and a live row
longer than its template is left alone.
Two faults of my own, caught while testing it end to end:
The notice was printed on stdout. This function returns its result
through a command substitution, so the notice text was captured INTO the
config value and written to navidrome's descriptor. It goes to stderr.
The width in the message was measured after the merge, so it reported
the post-merge count as the "before". Captured up front instead.
Verified against a live install: truncating navidrome's row to 9 columns
and running `config check` restores it to 11 with "music" intact, a
second run changes nothing, and no port on the box is left Traefik-
managed without a subdomain. Unit-checked that it declines to act on a
complete row, a non-port key, a row longer than its template, and never
overwrites a live value; quoting style is preserved either way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The parser accepted five shapes. Three of them (9, 10, 11/12) differ only
by trailing columns that have sane defaults, and those are worth keeping:
39 of the catalogue's descriptors stop at nine because they are
non-Traefik ports — DNS, SMTP, WireGuard UDP — with no subdomain to
state. A short row there is a complete row.
The other two were different animals. The 8-column legacy layout has no
login column and the 7-column one has no parent either, so they SHIFT
every position rather than omitting a tail: whenever the length was
misread, each field after the shift silently took its neighbour's value —
a port's access type reading from its protocol, and so on. That is the
same class of fault the word-splitting bug in this file just caused, and
it is invisible when it happens.
Nothing needs them. All 74 descriptors in the catalogue carry nine or
more, as does every one on this install. So they are refused now, with a
notice naming the offending key: a skipped port is visible, a mis-parsed
one is not.
Checked that skipping a row cannot misalign the parallel arrays —
port_config_data and port_config_vars are appended before the branch, but
neither is ever indexed alongside the others; the former is only tested
for emptiness.
Verified across every shape: 9, 10, 11 and 12 parse with the right
defaults, a label containing spaces survives intact next to an empty
trailing column, and both legacy layouts are refused rather than guessed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
App descriptions are read by people deciding whether they want the app,
not by people maintaining it. Several were spending their last clause on
facts the reader cannot act on and would not recognise — and in Stoat's
case actively talking the app down: "Heavy (16 containers) and does not
federate" is a maintainer's note, not a description.
Eight rewritten, all the same fault:
stoat "Heavy (16 containers) and does not federate", and LiveKit
named as though the reader would know what it is
vikunja "Runs as a single container on SQLite, with no database sidecar"
stalwart "in a single container"
gitea "written in Go", plus "self-hosted Git service" twice in one line
vaultwarden "an alternative implementation of the Bitwarden server API
written in Rust" — says what it is to a developer, not what it
does for you
speedtest "implemented in Javascript"
adguard "resolving blocked domains to a local blackhole address"
matrix "Installs Synapse plus the Element web client"
Deliberately kept, because they change whether the app suits you rather
than merely describing how it is built: Rocket.Chat's free-edition user
cap, Mattermost's unlimited users, Navidrome's Subsonic compatibility
(it tells you which phone apps will work), Stalwart's protocol list, and
Gluetun's provider count.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Provisioning the owner account did not stop anyone else signing up. Stoat ships
open, with no captcha and no email verification, so a reachable instance still
accepted walk-in registrations.
Made a config option rather than hardcoded, because running an open community
server is legitimate — but defaulting CLOSED, which is the opposite of the other
registration toggle in the tree. Vaultwarden's SIGNUPS_ALLOWED defaults true for
a reason that does not apply here: it has to let you register to get in at all,
whereas Stoat's owner account is now created for you. Matrix's
ENABLE_REGISTRATION already defaults false for the same shape of app.
The section name is load-bearing and not guessable. invite_only under [features]
or [api.security.authifier] is accepted in silence and does nothing — the API
keeps reporting invite_only=false — so it goes under [api.registration], which
was found by testing all three against a running instance. Anything but an
explicit "false" closes registration, so a blank or misspelled value fails safe.
Closing it broke LibrePortal's own tooling, which is the part worth noting: the
API answers MissingInvite to create_account too. So account creation now mints a
single-use invite and retries when it sees that. Reactive rather than reading the
config, so it follows the instance's actual state — someone who edits Revolt.toml
by hand gets the same behaviour. Stoat stamps the invite used/claimed_by as it
consumes it, and a failed create deletes it, so no reusable invite is left behind;
verified that the collection holds zero unused invites after two creations.
Verified end to end: a fresh install reports invite_only=true, a walk-in signup
is refused with MissingInvite, and the Create User Account tool still succeeds.
Flipping the config to false and reinstalling flips the API to open, and the
provisioning guard correctly reports "already has accounts" instead of trying to
claim a second owner. The value is baked into Revolt.toml at install, so changing
it needs a reinstall rather than a reload — now said in the config comment.
Unrelated flake seen once during testing and not reproduced: an install left
stoat-rabbit with no IP row, so the compose died on a literal IP_DATA_3. A
straight uninstall/reinstall allocated all 16 cleanly. Untouched here — it is in
the IP allocator, not this change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four faults, all in the same 12-column format, all silent.
The bash parser split with `local parts=(${value//|/ })` — replacing
pipes with spaces and word-splitting. That broke the format two ways at
once: a label containing a space became several fields, and an EMPTY
column collapsed rather than being kept, shifting everything after it.
Stoat's LiveKit row parsed as label "LiveKit", url_path "voice/video",
subdomain "(TCP", recommended "fallback)". Rocket.Chat's subdomain only
landed correctly because the extra label word and the collapsed empty
column happened to cancel out. The column COUNT was wrong too, so the
9/8/7-col compatibility branches were chosen from an inflated number.
Now an IFS read, which keeps empties and never word-splits.
The port editor had two serialisers and they disagreed. buildPortConfig
writes all twelve columns; updateIndividualPortFields wrote ten, dropping
subdomain and recommended — so saving ANY port on an app silently
discarded that app's Traefik subdomain. That is how Stoat's live config
came to differ from its template, which still had "stoat".
Both readers gated the subdomain on twelve columns, but subdomain IS
column eleven — so the canonical 11-column descriptor every web app
ships never surfaced one. The bash side reads it from nine.
Lastly, findMatchingCFGKey could not see a generated-value slot suffix.
Passwords LibrePortal generates are stored as CFG_<APP>_<NAME>_<n>, and
ADMIN_PASSWORD_1 neither equals ADMIN_PASSWORD nor ends with
"_ADMIN_PASSWORD", so a generic mapping matched an app's admin EMAIL and
missed its admin PASSWORD entirely: the field simply never rendered
unless someone had hand-written a per-app mapping. Now resolved as a
last resort, after every exact and whole-word match has failed, lowest
slot first. Plus a generic ADMIN_USERNAME mapping, since ADMIN_USER is a
different field name and correctly does not match it.
Audited all 74 port descriptors across the catalogue: none are
malformed. 39 sit at 9 columns, which is a documented, supported shape
(url_path/subdomain empty, recommended defaulting to the webui flag) and
they are all non-Traefik ports — DNS, SMTP, WireGuard UDP.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stoat shipped with no account and no way to make one from LibrePortal. It is
first-come-first-served, with invite_only=false, no captcha and no email
verification, so every install left a window between the API answering and
someone signing up in which anyone who could reach the port could take the
instance. The installer now claims the configured account as soon as the API
responds, and prints the credentials instead of "go and register".
Provisioning goes over HTTP, not Mongo: an account needs a login AND a
completed onboarding (accounts holds one, users the other) and passwords go
through Stoat's argon2 layer. Failure is deliberately non-fatal — it leaves the
instance exactly as it was before this existed, which must not fail an
otherwise good install of sixteen containers.
Both obvious config defaults are rejected by Stoat, which is only visible as a
failed install, so both are chosen against its rules: example.com comes back
DisallowedContactSupport (reserved domain) hence admin@stoat.local, and "admin"
comes back InvalidUsername (reserved) hence "administrator".
Two of the three missing adapter operations are now implemented:
- createUser: create, log in, complete onboarding. Without the last step an
account can sign in and then sits on a pick-a-username screen forever.
- setPassword: previously excluded because hand-rolling argon2 risks writing a
hash nothing can verify, locking the holder out with no error at the time.
That objection is answered by refusing to hash at all — authifier already
owns a reset flow, so this writes only its password_reset token to Mongo and
lets PATCH /auth/account/reset_password do the hashing with the same code
that verifies. Verified: reset by username and by email, new password logs
in, token consumed.
setAdmin is still NOT implemented, and the header now says so with evidence
rather than assertion. Stoat has no instance-level admin flag: the user
document holds only _id/username/discriminator and GET /users/@me adds only
relationship and online. Permissions are per-server bitfields on server_members.
A "make admin" button would invent a concept the app does not have.
Also fixed two things found while testing:
- post_start returned early when the public URL needed no settling, which
skipped everything after it — so provisioning would have been silently
missed on exactly the domain-backed installs that guessed the URL right.
- _stoatBaseUrl advertised $public_ip_v4, the WAN address from an external
resolver, in URLs compiled into the web client. Same fix as the APP_URL
processor: prefer $local_ip_v4, since LibrePortal never forwards ports.
Verified end to end on a clean install: the owner account is created and
onboarded, the generated password logs in, both new tools run through
`libreportal app tool`, and a created account survives a password reset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An empty chip is a dead end: clicking it can only ever produce "Nothing matches
this filter". Hidden rather than disabled, because a greyed-out chip still
occupies the eye, and "no apps have CVEs" is better said by the row of chips
being short than by a control you are told not to press.
Two exceptions. `all` is never hidden — it is the way back. And the active chip
stays put even at zero: a rescan can empty the filter you are standing in, and
pulling it out from under the selection would leave the list filtered by a chip
that is no longer on screen.
Verified against the running WebUI: Updates sits at 0 here and its chip is gone,
leaving All 14 / Newer 3 / Security 11, with every remaining chip still
selecting correctly. Forcing the filter to `updates` re-shows that chip marked
active, so the zero-but-selected case renders as intended.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The list rendered in whatever order the updater emitted, so the handful of rows
that actually want a decision sat wherever they happened to land — on a long
list, below the fold, under a wall of "up to date".
Rows are now ranked by what you can DO about them rather than by how alarming
they look, since an app with an Update button is one click from resolved while
an app with only a Details link is not:
0 update available, and the automatic attempt already failed
1 update available
2 a newer release line is published
3 CVEs, but nothing to apply
4 never scanned
5 up to date
Auto-failed leads because that build was tried and rolled back and will NOT
retry itself — it is the only row in the update group that strictly needs a
person. Worst severity orders within a rank, then display name, so the ordering
is total and stable across repaints.
Filtering already existed (All / Updates / Security); this adds the one state
that was unreachable. `newer_version` is deliberately excluded from `updates` —
its action is Upgrade, inside the row detail, not the row's Update button — so
it had no chip of its own despite being the state most likely to need a
decision, because it never resolves on its own: automatic updates only track
the line you are already on. The chip excludes rows already counted under
`updates` so the totals partition the list rather than double-count.
Verified against the running WebUI. With real data the three newer-version rows
lead (critical, critical, high, then alphabetical), followed by the eight
remaining CVE rows and the three clean ones — 3 + 8 matching the Security count
of 11. Temporarily flagging two apps update-available confirmed ranks 0 and 1
take the top, with the auto-failed row above the plain one; that data file was
restored byte-identical afterwards. Every chip was exercised by clicking it:
All 14, Updates 0, Newer 3, Security 11.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both apps were barred from multiple instances by the same rule: a
service or container name with no <app> prefix cannot be made unique per
instance, so a second copy would fight the first for it.
Prometheus is now fully unblocked. node-exporter and cadvisor became
prometheus-node-exporter and prometheus-cadvisor — service key,
container_name and SERVICE_TAG default together — and prometheus.yml's
scrape targets follow them. The image references (prom/node-exporter,
gcr.io/cadvisor/cadvisor) and the job_name labels are deliberately
untouched: the first are upstream coordinates, the second are labels
dashboards and alert rules key off, and renaming either would break
something to fix nothing.
Stoat's sixteen services are now all stoat-prefixed. Its container_names
already were, so only the service keys and their depends_on references
moved. The subtlety is DNS: compose registers the service name as a
network alias, so renaming a key silently retires the name anything
else resolves it by — Revolt.toml reaches the broker as "rabbit", the
Caddyfile proxies to api, events, autumn, january and gifbox, and the
bucket job talks to http://minio:9000. Ten services already carried an
explicit alias that survives the rename; the six that did not
(database, redis, rabbit, createbuckets, crond, pushd) have been given
one holding their old short name. Every name that resolved before still
resolves. Verified by parsing the result: 16 services, every one keeping
its short alias, and no dangling depends_on in either app.
Stoat stays non-instanceable, but for one narrower reason now, recorded
in its config: LiveKit advertises its own tcp_port 7881 and UDP range
50000-50100 to clients from livekit.yml, so those cannot be randomised
per instance without generating that file per instance and allocating a
range for each.
Stoat's port descriptors name their parent service, so they follow the
rename too: caddy -> stoat-caddy, livekit -> stoat-livekit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The instances line had two states where there are three. An app with a
blocker cannot be instanced; an app whose flag is explicitly false was
reviewed and deliberately kept to one — the resolver clients point at,
the auth provider every router points at, the scanner the updater
resolves by a fixed container name; an app with no flag has simply never
been looked at.
Collapsing the middle case into the last reported a decision as an
oversight, and invited someone to "fix" it by flipping the flag. Those
apps now read "Not offered" and point at the reason recorded above the
flag in their config, rather than "not been opted in".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit enabled everything that passed the two mechanical
checks. Passing them is necessary, not sufficient: several of those apps
are singletons by role, and an instance of them would validate, clone,
start, and then not make sense.
Eight are now off by design, each saying why:
adguard a resolver is what clients point at
authelia the forward-auth provider every Traefik router points at
gluetun a network provider — apps join it by container name
headscale the control server a tailnet is defined by
libreportal_catalog LibrePortal's own catalog, internal plumbing
ollama one endpoint, and gigabytes of models per copy
trivy the updater resolves the scanner by a FIXED container
name, trivy-service, so a second copy would run and
never be the one CVE scanning uses
wireguard one stable published UDP endpoint; peers are tied to it
And one that should never have been touched: crowdsec ships no
docker-compose.yml, so the audit — which required a compose to read
service names from — skipped it, while the enabling pass only required a
config and did not. It got an unaudited true. There is nothing for
`instance create` to clone, and one decision engine watching the whole
box is the point of it. Now false, with that stated.
23 apps instanceable, 15 not: 6 that cannot be, 9 that should not be.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 reason spent a full line of every non-instanceable app's page on a
sentence only someone asking "why not" wants. It now shows a short
"Not available" / "Not enabled" with a hover-and-focus target carrying
the technical detail.
It also says more than it could before. The old text knew about one
blocker — a pinned host port — and its own comment conceded the rest was
"left unexplained rather than guessed at, since the remaining blocker
lives in the compose, which the frontend never sees". It does see it:
apps.json carries each app's service list, so the second rule
instance_create.sh enforces (a service or container name with no <app>
prefix, which cannot be made unique per instance) is reproducible here.
Prometheus now says node-exporter and cadvisor rather than nothing.
And it separates two states that read identically before. An app with no
blocker at all is not "unavailable", it is simply not opted in — a
different sentence with a different fix, and the one Matrix was hitting.
Keyboard reachable via tabindex, with aria-label duplicating the tooltip
since screen readers do not reliably surface a title attribute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The row head is the click target that expands a row and already set
cursor:pointer, but nothing changed under the pointer — so a clickable row
looked identical to a static one right up until it opened, and the only visual
state was post-click.
The tint is painted by the row rather than the head. .ov-row's `padding: 0`
loses to .updater-row's `padding: 12px 15px` on equal specificity, so the head
is only 34px tall inside a 60px row; filling the head left a floating band with
13px of untinted row above and below it. Painting the row covers the full
height and picks up its 11px radius for free.
Keyed on `:has(> .ov-row-head:hover)` rather than `.ov-row:hover` so the
highlight tracks what is actually clickable: on an expanded row, drifting into
the detail body stops indicating a click target, because there isn't one there.
The head-level rule stays as the fallback for engines without :has() — it still
marks the row, just inset — and the pair is mutually exclusive so the two tints
never stack. :has() is already relied on elsewhere in the WebUI CSS.
Verified against the running WebUI by driving Chrome over CDP and dispatching a
real mouseMoved, since lp-shot cannot hover: the hovered row goes from
rgba(255,255,255,.035) to .086, its neighbour stays at .035, and the head fill
reads transparent so nothing double-tints. Confirmed visually in the capture
too. ov-row-head is emitted only by renderUpdates(), so no other tab changes.
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>
"06:00-08:00" is unambiguous on paper and ambiguous at a glance — the
strip said updates install "during 06:00–08:00" and left the reader
working out whether that meant morning or night. The window now renders
as 6am–8am. The config stays 24-hour so there is still one canonical way
to type it; only the display spells it out, and anything that is not a
plain HH:MM-HH:MM passes through untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
updaterSetAnchorVersion located the anchor by looking for a service
literally named "<app>-service". That is a convention, not a rule:
matrix names its anchor service matrix-synapse and stoat names its api.
For those apps nothing matched, so the rewrite changed no lines and the
upgrade aborted at step 2 with "could not set version" — after having
already taken a snapshot. Dry runs never showed it because they return
before that step. It now finds the anchor by its bare <APP>_VERSION_TAG
sentinel, the same way updaterPrimaryImage does.
It also moved only the anchor. Some apps are one product shipped as many
images: stoat is nine stoatchat services released together, all on
v0.15.1, expecting matching versions of each other. Stepping the anchor
alone would have put api on v0.16 while events stayed on v0.15.1 — the
exact mismatch that once justified keeping the app off automatic
updates.
The lock-step set is DERIVED from the compose rather than configured,
because the compose already states it: a service moves with the anchor
when it carries a version sentinel, sits on the SAME tag, and shares the
anchor's registry namespace. Both tests are load-bearing and each
rejects a real case — livekit-server is same-namespace but on its own
cadence, for-web is pinned to a commit hash, element-web is a different
namespace entirely, and mongo has no namespace at all. Verified against
copies of five composes: stoat moves all eight sibling services and
nothing else; matrix, rocketchat and nextcloud move exactly one image.
Every sentinel that moved gets its CFG_*_VERSION key set, not just the
anchor's, or the next config-driven regeneration would quietly pull the
locked-step services back to the old version.
One trap worth naming: quotes were stripped with sed 's/["\047]//g',
but \047 is an octal escape awk honours and sed does not — in a sed
bracket expression it is the literal characters \ 0 4 7, so it deleted
every 0, 4 and 7 it saw and v0.15.1 arrived as v.15.1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GATE 1 refuses to ladder an app that cannot prove a rung landed, and
only mastodon, nextcloud and stalwart could. None of those are installed
here, so the stepped upgrade — button or automatic — was unreachable for
every app on the box.
Three fixes.
_updaterPrimaryContainer assumed the container is "<app>-service". It is
a convention, not a rule: matrix names its anchor service matrix-synapse
and stoat names its api (container stoat-api). The verifier therefore
inspected a container that does not exist, saw no state, and could only
time out — on exactly the stateful apps that most need verifying. It now
reads the anchor service's container_name from the compose, buffering
per service block because container_name may sit either side of the
image line.
Added updaterVerifyHttpVersion: poll the app over its PUBLISHED port
from the host, pull the version from a JSON field or a response header,
and require agreement three polls running. Probed from the host rather
than `docker exec … curl` because half these images ship no curl at all
(mattermost is one), so exec-based probing is a coin flip on the
vendor's base image. Version comparison matches only the components both
sides state, since tags and self-reported builds rarely share precision:
v1.158.0 vs 1.158.0, 11.9 vs 11.9.1, 8.7.0 vs 8.7 all agree; 11.9 vs
11.10 does not.
Each app hook is then three facts. Verified live: all three confirm at
the version they are actually on, and all three REFUSE a version they
are not — which is the property that makes stepping them safe.
updaterUpgradeAuto now skips apps with no verifier instead of queueing a
task that GATE 1 will reject, which would otherwise mean a failure
notification every day for an app that was never eligible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The hook substituted `simple_style: auto`, a line that only exists in SearXNG's
full bundled settings.yml. The file generated here is the minimal
`use_default_settings: true` form with no ui: block at all, so the sed matched
nothing and the theme setting had never taken effect on any install.
It could not have worked even with the right pattern: the entrypoint chowns
settings.yml to searxng:searxng (uid 977) mode 644 on first start, so the
host-side docker user cannot write to it. The edit now runs inside the
container via docker exec, targeting the real key path
ui.theme_args.simple_style.
Three shapes are handled so the hook stays correct on repeat installs and
alongside hand edits: substitute in place when simple_style already exists,
nest theme_args inside an existing ui: block rather than appending a second one
(a duplicate YAML key SearXNG refuses to load), and otherwise append the whole
block. All three were exercised against the running container and produce valid
YAML with exactly one ui: block. awk rather than `sed a\` for the nesting case,
since busybox sed does not expand \n in appended text.
The value is validated against auto|light|dark|black before being written.
SearXNG checks it at startup and exits on anything else, so an unrecognised
CFG_SEARXNG_THEME would have taken the app down instead of merely looking
wrong; it is now reported and the default left alone.
Verified end to end on a base install and a --local instance: both come up,
serve 200, and report Dark as the selected style on /preferences, each with its
own settings.yml and secret_key. The instance's cloned hook correctly reads
CFG_SEARXNG_PROBE_THEME and targets its own container, since the container name
is built from $app_name. Both test installs were removed afterwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SearXNG never started on rootless Docker — base install or instance, it
restart-looped from the first boot and the web UI was never reachable.
Cause is a specific capability combination, not a general permissions problem.
The image's entrypoint runs as root and, on first start, chowns the mounted
config dir to searxng:searxng (uid 977) using CHOWN, then immediately copies
settings.yml into the directory it has just given away. The compose dropped ALL
capabilities and added back only CHOWN, SETGID and SETUID — so the chown
succeeded and the write did not, because root cannot write to a 0755 directory
owned by another uid without DAC_OVERRIDE:
cp: can't create '/etc/searxng/settings.yml': Permission denied
!!! "/etc/searxng/settings.yml" is not a valid file, exiting...
Isolated the cause by running the image against fresh directories: it fails
with the shipped cap set and succeeds with DAC_OVERRIDE added, everything else
identical. Granting CHOWN without DAC_OVERRIDE is the exact breaking pair; all
other capabilities stay dropped.
Verified end to end: base install and a --local instance both come up and serve
200 on their own random ports, each with its own settings.yml and a distinct
secret_key. This also completes the instance verification that was blocked
earlier — searxng could not be runtime-tested while the app could not boot.
Both test installs were removed afterwards.
Separate pre-existing bug, not fixed here: the install hook applies the theme
with sed s/simple_style: auto/.../ but the generated settings.yml contains no
style keys at all, so the substitution matches nothing and CFG_SEARXNG_THEME
has never had any effect. Fixing it means inserting a ui: block rather than
replacing a line, which is a different mechanism and wants its own change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by installing vaultwarden and one instance end to end. The template built
DOMAIN as https://<subdomain>, and blanking host_setup on a box with no
CFG_DOMAIN_n set (earlier in this branch) left it as a bare "https://".
Vaultwarden validates that value and exits:
Error validating domain: empty host
DOMAIN variable needs to contain the protocol (http, https)
APP_URL is already the address the app is reached at in both worlds —
https://vault.<domain> behind Traefik, http://<lan-ip>:<port> without it —
which is exactly what vaultwarden means by DOMAIN. Prior to the blanking the
value was "https://<app>." with a trailing dot, which started but pointed at a
host that never resolved, so this was broken before too, just quietly.
Verified: base and instance both come up and serve 200 on their own random
ports, each with DOMAIN set to its own address, and with distinct IPs and admin
tokens. Both were then removed; nothing left behind.
Four other apps interpolate the same legacy DOMAINSUBNAME_DATA into env vars
and get an empty value with no domain configured — gitea (DOMAIN, SSH_DOMAIN,
ROOT_URL), mastodon (LOCAL_DOMAIN), owncloud (OWNCLOUD_DOMAIN), jitsimeet
(PUBLIC_URL). They start rather than exit, so the breakage is quieter, and the
fix is not uniform: ROOT_URL/PUBLIC_URL want a URL like this one, while
DOMAIN/SSH_DOMAIN/LOCAL_DOMAIN want a bare host that APP_URL cannot supply.
Left alone pending that decision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both now clear every guard: services are <type>-prefixed, and their host ports
became random in the previous commit.
Enabling them surfaced a real bug that would have made vaultwarden instances
fail to start, found by dry-running the clone path before trusting the flag.
Eight apps define an app-specific compose-tags hook named with the app as a
SUFFIX — appSetupComposeTags_vaultwarden — and docker_config_setup_data.sh
dispatches it as appSetupComposeTags_${app_name}. The tools rewrite only
renamed the <type>_ PREFIX form, so a clone kept the base name: it defined a
function nobody calls (colliding with the base app's), its ADMIN_TOKEN and
SIGNUPS_ALLOWED tags were never filled, and the pre-start guard would have
refused to launch the instance. Now renamed, anchored on the () of a definition
so only real function names are touched.
The same hooks pass tag NAMES as strings ("VAULTWARDEN_ADMIN_TOKEN_1_TAG"),
invisible to the lowercase renames, while the cloned compose had already moved
to <SLUG>_..._TAG. Those are rewritten too, mirroring compose rule 4. Verified:
the tags the cloned hook sets now match the cloned compose exactly.
Also affects matrix, nextcloud, speedtest, pihole, gluetun and wireguard, which
ship the same hook shape — latent for those, since none are enabled.
WebUI: the instance bar on app details rendered nothing at all for apps without
instance support, which reads as "this build has no instance feature" and sends
people hunting for a setting that isn't missing. It now states the reason where
the pills would be, and names the blocking ports when it can — the port rows
are in the config the frontend already holds, so it mirrors
_instanceCheckPortsInstanceable (skipping disabled and random rows). The other
blocker lives in the compose, which the frontend never sees, so that case is
left unexplained rather than guessed at.
Bookstack's rewritten compose and tool tree remain byte-identical to the
running instances.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both pinned an arbitrary host port — vaultwarden 8201:80, searxng 8083:8080 —
which was the only thing blocking them from being instanced. Neither number is
meaningful the way pihole's 53 or stalwart's 25 are, so both become
random:<internal> and portAllocate assigns each install (and each future
instance) its own. The ports appeared nowhere else: no hook, no compose, no
docs. Neither app is installed on the maintainer's box, so nothing to migrate.
Both now clear every instance guard. Of the eight apps the port guard caught,
that leaves six, all genuinely one-per-host.
Also made compose rewrite rules 2 and 3 skip commented lines, for the same
reason rule 1 already does. Spotted while verifying the above: vaultwarden
parks an optional exporter behind #, and rule 2 rewrote the container_name
inside that dead block while the service key above it kept the old name,
leaving it internally inconsistent. Harmless — rule 2 is anchored on
container_name: so it could never reach the image line — but there is no reason
to touch a commented block at all. Bookstack's rewritten identities remain
byte-identical to the running instance.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit of the per-app install hooks for singleton assumptions. The naming work
so far made identities unique, but a second copy still has to bind its own
ports, and `8201:80` is the same 8201 for every instance — the second container
simply fails at compose-up. `random:<internal>` is what makes an app
instanceable, since portAllocate then hands each instance its own host port.
Seven apps are caught: pihole (53 tcp+udp), stalwart (25/465/587/993), unbound
(5335 tcp+udp), traefik (443), searxng (8083), vaultwarden (8201), stoat
(7881). The message distinguishes the two cases, because they need opposite
fixes: an arbitrary pin like vaultwarden's 8201 should just become random,
while a DNS server on 53 or a mail server on 25 is genuinely one-per-host and
should never be instanced.
Runs before anything is cloned — this is a property of the app, not of the
instance. Bookstack is unaffected (all its ports are already random).
The rest of the hook audit found nothing further to fix:
- No hook writes to another app's config or deployed directory. The three that
reference ${containers_dir}traefik / headscale only test [[ -d ]] to detect
whether those are installed.
- Only two hooks read a foreign CFG_ namespace, and both are system-wide
settings (CFG_DOCKER_INSTALL_TYPE, CFG_ENABLE_VIDEO), not another app's.
- No app declares a fixed container IP; all come from IP_TAG allocation.
- Host-level writes are limited to wireguard's sysctl IPv4-forwarding drop-in
(global and idempotent) and its /etc/wireguard/params conflict probe. Traefik
writes only under $containers_dir$app_name. Stalwart's /etc/stalwart path is
inside its container.
Not mechanically checkable, so left as maintainer judgement: gluetun is a
network provider other apps join via network_mode container:gluetun-service,
and it plus wireguard hold NET_ADMIN and /dev/net/tun. Both are one-per-host
for reasons no guard can see.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was the only underscore-separated service name across all 38 apps; every
other helper uses a hyphen (nextcloud-db, matrix-postgres, owncloud-mariadb,
gitea-cache, mastodon-redis).
Beyond consistency this closes a naming collision by construction. Instance
slugs are <type>_<id> and may only contain [a-z0-9_] — the underscore is forced
there, because app configs are SOURCED and the uppercased slug becomes part of
CFG_<SLUG>_* variable names, which a hyphen would make invalid shell
identifiers. So a hyphenated helper name is one no slug can ever produce:
bookstack_home-db is unreachable, where bookstack_home_db was a name an
instance literally called "home_db" could also generate.
The four installed Bookstack apps were removed and rebuilt on the new template
(no data worth keeping, per the maintainer). All four are back up and serving
200 on the LAN, and their port rows join correctly to the renamed services.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two halves: the ladder could not climb the commonest versioning scheme,
and nothing ever climbed it on its own.
The ladder stepped by bumping a tag's LAST numeric component, so
v1.158.0 went v1.158.1, v1.158.2, … and never arrived at v1.159.0. It
then failed closed, refusing to build a path. Synapse publishes
v1.159.0 and no v1.158.1 at all, so Matrix could not be laddered by the
button either — three-part semver minor bumps were simply unreachable.
updaterNextRung now considers a bump of every component, keeps the
candidates that exist upstream and takes the smallest: the next release
by definition, whether it lands in the patch position or crosses into a
new major. Shape discipline is unchanged, so 31-fpm-alpine still never
becomes 31-apache, and each rung is still probed, so none can be
skipped. updaterTagBumpAt moves here from the scan, its natural home,
which also breaks a source cycle.
updaterUpgradeAuto then climbs at most ONE rung per app per calendar
day, inside the install window, for apps set to auto. One rung because a
ladder run unattended can be several migrations deep before anyone
looks, and "restore the snapshot from a minute ago" stops comforting
once four have stacked; one a day so there is time to notice. It crosses
a major if that is genuinely the next release — refusing would strand an
app on the last version of its line forever — but one step at a time,
never as a leap. Two stamps: the target rung (a failure is not retried
until something newer ships) and the day.
Every rung goes through updaterUpgradeApp unchanged, so GATE 1 still
refuses any app without a real verifier, and the per-rung contract is
identical to the button: snapshot fail-closed, set version, pull, up,
verify, restore that rung and stop on any failure. History now records
the trigger instead of hardcoding "manual", including on the rollback
paths. CFG_UPDATER_LADDER_AUTO gates the whole thing separately from
CFG_UPDATER_AUTO, because "keep my apps patched" and "move my apps
between versions unattended" are different appetites for risk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit of per-app hooks/tools found 19 of 33 apps whose helpers would have
operated on the BASE app after cloning. Two general causes, both fixed by
rewriting classes rather than patching apps:
- Container references escaped the rewrite whenever a flag sat between the
docker verb and the target (`docker exec -u git gitea-service …`), since the
old rule only matched a name immediately after the verb — and the hyphenated
form missed the `<type>_` rule too. Hook trees now get the same discovered
identity rename the compose does, reading names from the TYPE's compose since
the clone has already been rewritten by then. Safe to apply broadly: the
compose pass runs first and aborts for any app whose identities aren't
<type>-prefixed, so a bare word like stoat's `api` never reaches it.
- Hooks that build the deployed path as "${containers_dir}<type>/..." instead
of "$containers_dir$app_name/..." read and WROTE the base app's files —
adguard's auth adapter edits AdGuardHome.yaml, so an instance would have
rewritten the original's config. The trailing slash is optional in the match:
dashy tests [[ -d "${containers_dir}dashy" ]] and gluetun cds into it, both
ending at the quote. Only the first path component is touched, so
${containers_dir}prometheus/prometheus/... keeps its inner segment.
Re-audit: all 33 apps with hook trees are clean. Stoat still leaks, but it is
refused at the compose stage and never reaches this code.
Volumes audited too, and need no changes: no app uses named volumes, so the
./relative bind mounts every app uses resolve inside each instance's own
deployed dir. The absolute sources that exist are host or in-container paths
correctly shared read-only (/etc/localtime, /sys, /etc/ssl/certs). Jitsi's
${CONFIG} is set per-app by its own hook to $containers_dir$app_name/... and so
follows the slug.
Bookstack's rewritten tool tree is byte-identical to the live instance's across
all 8 files, so the running instances are unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stoat wore MongoDB's identity. Its services are named database / api /
events / …, so there is no stoat-service for updaterPrimaryImage to
match, and the fallback took the FIRST image line — mongo:8.0. Every
downstream fact inherited that: the app's version read 8.0 instead of
v0.15.1, its "8.3 available" chip was a MongoDB major dressed as a Stoat
release, its CVE scan covered mongo and none of the nine Stoat images,
and pressing Upgrade would have laddered the database 8.0 -> 8.3 beneath
a live sixteen-service stack.
The compose already says which image is the app's: every image line
carries a #LIBREPORTAL|<KEY>_VERSION_TAG| marker, and the one keyed on
the BARE app name (STOAT_VERSION_TAG, not STOAT_MONGO_VERSION_TAG) is by
construction the app's own version. 37 of 38 apps have exactly one; only
libreportal lacks it, and the scan skips that app anyway.
Ask the sentinel first, keep <slug>-service and first-line as fallbacks.
Verified across the catalogue: identical anchor for every app except
stoat, which is corrected. This is the ollama mislabel of P0 recurring
through a different hole — positional guessing — closed with the
metadata that was already there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The compose rewrite assumed each app had exactly <type>-service and <type>_db.
That holds for Bookstack and almost nothing else: cloning Nextcloud left -db,
-redis and -web pointing at the ORIGINAL app's containers, and Matrix, Ollama,
Mastodon, Owncloud, Gitea, Jitsi, Invidious, Rocketchat and Mattermost all had
the same hole. Docker refuses a duplicate container name and two Traefik
routers sharing a name fight over the host, so those clones could not have
worked.
Service identities are now discovered from the compose itself — its
SERVICE_TAG_<n> markers plus its container_name values — and each is renamed.
Verified across all 38 shipped apps: 15 are fixed, 21 produce byte-identical
output to the old rule (Bookstack among them, so the running instances are
unaffected), and 2 are refused.
Details worth knowing:
- Separators compare as equivalent, so the app dir libreportal_catalog matches
its libreportal-catalog-* services instead of being wrongly refused.
- Tokens are substituted longest-first through placeholders. \b has to end a
token because per-port routers are named <service>-<portname>
(traefik.http.routers.adguard-service-webui), which also means a short name
could otherwise match inside a longer one — ordering is what prevents that.
- Commented-out lines are not harvested. Several templates park an optional
sidecar behind # (adguard-exporter, pihole-exporter, wireguard-exporter);
renaming those also mangled the image name in the same block, leaving a trap
for anyone uncommenting it. Commented image: lines are skipped too.
- image: lines are genuinely excluded now. The old comment claimed service
tokens "never appear in an image path", but libreportal builds a local image
named after its own service and the old rule rewrote that reference.
An app with a service carrying no <type> prefix (stoat's api/database/minio,
prometheus's node-exporter/cadvisor) cannot be made unique mechanically, and
rewriting a bare word like minio would corrupt image: minio/minio. Those are
refused with an explanation and the partial clone is removed, rather than
handed back as an instance that silently fights the base app.
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>
Found while installing two LAN-only Bookstack instances — both in the same
no-domain path as the previous commit:
- apps-services.json advertised every app at http://localhost:<port>. The
CFG_SERVER_IP override it reads is defined in no config file, so the lookup
always fell through to the "localhost" default — a URL that only resolves for
someone browsing on the server itself. Now falls back to $local_ip_v4, the
same host APP_URL is stamped with.
- traefikManaged was inferred from `access == public`, a stated placeholder.
Public only means the port is published on the host; it says nothing about a
router. It reported true for both new instances despite their compose having
traefik.enable:false. Now read from the port's own traefik column, gated on
the app's domain actually being set — resolved per-app here rather than from
$domain_full, which this generator never populates.
- The "New instance" modal led with bare text. It now shows the type's icon in
the same .app-card-icon holder the grid cards use, so it's visually tied to
the app the user clicked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two display bugs the newer-version work made visible.
A fleet row showed a green "✓ Up to date" directly beside a
"1.159.0 available" chip. Both statements are individually true — you
ARE current on the line you track, and changing lines is a deliberate
act — but a row is a glance, not a place to reconcile two chips that
appear to disagree. The green all-clear now gives way to a neutral
"Newer version" whenever a newer release line exists; the chip still
carries the number and the tooltip still explains the move. The per-app
detail deliberately keeps "up to date" and is left alone: there the
badge arrives with a sentence explaining the distinction and an Upgrade
button, which is what makes it readable.
updaterDisplayVersion preferred the OCI version label unconditionally.
That label is inherited from the vendor's base image unless they
overwrite it, so it can describe the OS rather than the app: mongo:8.0
carries org.opencontainers.image.version=24.04, its Ubuntu base, and
Stoat's row read "24.04 → 8.0 · 02a0cc7" — not a version transition at
all. When the tracked tag is versioned we already hold an authoritative
version, so the label now wins only if the two agree on their leading
number. Keeps nextcloud 34 → 34.0.1, rejects mongo 8.0 vs 24.04, and
leaves rolling tags untouched since the label is the whole point there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An instance's isolation never needed a domain — its own slug, dir, secrets,
IP and randomly-allocated host port already make two copies independent. But
the routing layer assumed one, so a LAN-only box got a broken instance rather
than a port-served one. Four fixes:
- instanceCreate now rewrites the parent-service column of the cloned config's
PORT_ rows to match the service names it stamps into the compose. That value
is stored as network_resources.parent_service and joined against the
compose-derived service names, so an instance left carrying the TYPE's
service name matched nothing: it rendered in the WebUI with no port, no URL
and no login row despite being up and reachable.
- `instance create --local` (plus a LAN-only toggle in the modal) forces every
port to access=private, traefik=false, for a second copy that should stay
off the domain even when one is configured.
- initializeAppVariables forces the traefik column false when no CFG_DOMAIN_n
is set. Previously a traefik=true port with an empty domain stamped
Host(`app.`) — a trailing-dot host matching nothing — and dragged APP_URL to
https://app. with it, breaking every app that builds its links from APP_URL.
host_setup is blanked for the same reason. The published host port is
untouched; access type, not the traefik flag, gates allocation.
- APP_URL's direct host-port branch now prefers a new $local_ip_v4 (the source
IP for the default route) over $public_ip_v4, which is the WAN address from
an external resolver. LibrePortal never forwards ports, so the WAN address
was unreachable for exactly the LAN/VPN clients that branch serves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>