Repository navigation
security(redis): the compose file publishes an unauthenticated Redis on all interfaces #17500
Description
Raised by the CodeRabbit review on #17438 (docs/api/redis-documentation.md:26), verified against the compose file. Filed separately because #17438 is documentation-only and this is a docker-compose.yml defect — the doc merely mirrors it.
Defect
docker-compose.yml publishes Redis on every host interface while the image runs with protected mode off and no password:
ports:
- "${AUTOBOT_REDIS_PORT:-6379}:6379"
- "127.0.0.1:8001:8001" # RedisInsight UI (localhost only)The two lines are adjacent and only the second is bound to loopback. A Docker port mapping without an address binds 0.0.0.0, so the Redis line reaches anything that can route to the host.
Three facts have to hold together for this to be exploitable, and all three do:
- No loopback binding — the line above, versus the RedisInsight line that has one.
- Protected mode is off, and not by our choice. The
redis/redis-stack:7.4.0-v1entrypoint invokesredis-serverwith--protected-mode nohardcoded. Protected mode is the fallback that would otherwise refuse external commands from an unauthenticated client, so the usual safety net for an unconfigured Redis is already removed by the image. - No password.
REDIS_ARGS=--appendonly yes --save 60 1 --maxmemory 512mb --maxmemory-policy allkeys-lrusets no--requirepass, and no mounted config supplies one.
Impact: unauthenticated read/write to the whole keyspace for anyone who can reach the host on that port, including FLUSHALL and CONFIG SET. Redis holds session and cache state here, so this is not only data exposure.
AUTOBOT_REDIS_PORT changes the port but not the binding, so an operator who sets it gets the same exposure on a different number.
Suggested fix
- Bind to loopback like the neighbouring line:
"127.0.0.1:${AUTOBOT_REDIS_PORT:-6379}:6379", and where a non-local client genuinely needs Redis, reach it over the compose network by service name rather than the published port. - Set
--requirepassfrom the canonical secrets manager, never a literal in the compose file or the environment block. Defence in depth: the binding is the control, the password is the backstop.
Acceptance criteria
- The Redis port mapping carries an explicit loopback address, or a documented justification exists for why it must not.
- A test or guard asserts no published port in
docker-compose.ymlbinds all interfaces without an explicit allowance — the class matters more than this one line, since the same mistake is one copied line away. -
--requirepassis supplied from the secrets manager, and a startup check fails closed when Redis is reachable without auth. -
docs/api/redis-documentation.mdshows the corrected mapping, so the documentation stops teaching the insecure form.
Activity
mrveiss commentedon Sep 27, 2026
Confirmed live on the deployment — and the firewall does not mitigate it
This issue was derived from the compose file. Measured read-only against the running deployment
just now, all three conditions hold in practice, and a fourth makes it reachable rather than
theoretical. Nothing was modified, no credentials were used, and no key contents were read.
The keyspace is non-empty, so this is live state rather than an idle instance.
The fourth row is the point of this comment. The issue says the exposure reaches "anything that
can route to the host", which reads as a conditional. On this deployment the host firewall has an
explicit allow for that port scoped to a routable /24, so the exposure is not hypothetical and is
not blocked by default-deny — every host on that subnet has unauthenticated read/write to the whole
keyspace, including destructive commands.
This is the one home for the defect
A sibling finding (#17635) covers the metrics service being firewall-open and unauthenticated. That
is the same class — reachable implies authenticated — but a different instance with a different
repair, so it stays there and this stays here. Both should be weighed together when the class is
scheduled, because the store holds data where the metrics service holds labels.
Note for whoever fixes this
Any password must come from the canonical secrets manager, never a literal in compose or an env file.
A fix that hardcodes a value trades one finding for another.
The repair also has an ordering property worth stating: the store is currently usable because it is
unauthenticated, so enabling the password and narrowing the bind will break every client that does not
yet supply credentials. Those land together or the deployment loses its cache.
What was not established
Whether anything on that subnet currently connects to the store, and whether the exposure predates the
current deployment. Both need evidence I did not gather; neither changes the finding.
mrveiss commentedon Sep 27, 2026
Scoping: clients exist, so the cheap repair is not available
Established from the tracked tree, not live connection state.
1. Client inventory — remote clients exist by design
The ansible inventory places these in distinct host groups, one host each:
backendgroup_vars/backend.yml:108—AUTOBOT_REDIS_HOST: "{{ redis_host }}"aimlgroup_vars/aiml.yml:91and:132—REDIS_HOST: "{{ redis_host }}"databasegroup_vars/infrastructure.yml:71resolves the endpoint toredis://{{ infrastructure.hosts.database }}:{{ infrastructure.ports.redis }}—so
redis_hostis the database host, andbackendandaimlare configuredto reach it across the network.
So the answer to the deciding question is yes. Not from live state — from
configuration that is committed. Consequences:
cheapest option is off the table.
backendandaimlin the same change, or thoseservices lose the cache the moment
requirepasslands.One negative result worth recording: the only literal remote address in the tree
is in a frontend test fixture
(
autobot-frontend/src/config/__tests__/ssot-config.spec.ts), not a clientconfig. And the SSOT defaults are local —
ssot_config.py:187defaults the hostto loopback,
:2037tolocalhost. So there is no additional undeclared clientbeyond the two groups above.
2. Which firewall description is authoritative — and the dead one is worse
Authoritative:
roles/redis/tasks/main.yml:175-184That is what produces the live subnet-scoped rule, and it is the file a repair
belongs in.
Unapplied:
autobot-infrastructure/shared/config/ufw-defaults.sh:108ufw allow 6379/tcp comment 'Redis Stack'Referenced by exactly two things:
docs/developer/INFRASTRUCTURE_DEPLOYMENT.mdand itself. And it is more permissive than reality — no
fromrestriction atall, so it allows from anywhere, where the applied rule is subnet-scoped. So it is
not merely dead:
narrowing it.
That second point is worth fixing regardless of this issue's outcome.
3. A latent hazard in the authoritative rule itself
roles/redis/defaults/main.yml:7:The final fallback is the empty string, and
from_ipis used unguarded atmain.yml:180and:191. Whatufwdoes with an emptyfrom_ipneeds checkingbefore the repair is written — if it degrades to "from anywhere", then a host
where neither the environment variable nor the secrets file resolves is silently
wide open, and nothing reports it. I have not established that behaviour and am
not assuming it.
4. Recommended repair, and where it lives
All three parts in one change, since any one alone leaves a broken or exposed
state:
config by
roles/redis— never a literal in compose, never an env file.Note the existing obstacle:
redis_passwordresolves throughvault_redis_password, which nothing in the tree assigns, and theSLM-generated secret is adopted into
redis_client_password— the clientcredential — after the server config renders. That split is security(redis): P1b — SLM-generated Redis password, every consumer authenticates as default while the server stays nopass (#13568) #16627 and it is
the precondition, not a detail.
group_vars/backend.ymlandgroup_vars/aiml.yml,from the same source, in the same change.
roles/redis/tasks/main.yml. There is already a precedent for exactly this inthe same role —
roles/redis/tasks/redis_exporter.yml:89-96scopes a rule withfrom_ip:to one host, for the Prometheus scrape.Evidence must be a refusal: an unauthenticated client rejected on a
provisioned node, read back from the host. Not the absence of a report.
5. Relationship to #17572
This issue is the compose path; #17572 is the provisioned path, and both
are open. They share the mechanism worth stating once: the vendor start path
disables protected mode unconditionally — this issue's point 2 records the image
entrypoint hardcoding
--protected-mode no, and #17572 records the packagedredis-stack-serverwrapper passing--protected-mode noafter the configfile it is handed, so anything the config sets is overridden.
That means protected mode cannot be part of either fix. A gate attempted in
the config layer was reverted from #17444 for exactly that reason. The only
controls available are the credential, the bind, and the firewall.
Not implementing — scoping only.
mrveiss commentedon Sep 27, 2026
The open question from my scoping is settled: an empty
from_ipdoes degrade to "from anywhere"Answered from the installed module source —
community.general9.5.2 — not byapplying a rule. No firewall was touched.
plugins/modules/ufw.py:405:Each fragment is a
[value, rendered]pair, andfrom_ipis built at:568-571as
[value, "from %s" % value].filter(itemgetter(0), …)keeps a fragment onlywhen the value is truthy, so an empty
from_ipdrops thefromclauseentirely — not "from empty", but no source clause at all:
Valid syntax,
rc 0, no warning, normalchanged/ok, and ufw applies no sourcerestriction when none is given. So yes — and it fails in the direction that
does not announce itself.
(The module declares
from_ipwithdefault='any'at:354, which is a redherring: defaults apply only to an omitted parameter, and this task passes one
explicitly. The widening is the drop, not the default. Same destination either
way.)
So this issue is worse than my scoping measured, and in two respects:
the variable resolves.
roles/redis/defaults/main.yml:7ends inor '', andfrom_ipis unguarded atmain.yml:180and:191.roles/browser/tasks/main.yml(:347,:361,:374), and eight definitionsof it across the inventory fall back to either
''or0.0.0.0/0, both ofwhich mean anywhere.
That spans two roles and is a different fix site from this issue, so I filed it as
its own defect rather than folding it in here: #17642. It also has a lineage
worth knowing — #3301 and #7162 both fixed this variable for the opposite symptom
(an empty value breaking the play) by making it resolve, which replaced a loud
failure with this silent one.
One precision, so this is not read as a live claim: I am not saying the
deployment is open right now. #17635's read-only measurement records the cache as
allowed from a routable /24, so the variable did resolve on that node. This is a
reachable latent path, not an active exposure — and the reason it deserves a fix is
that the two states are indistinguishable in the playbook output.
What it changes for this issue's repair: part 3 (narrow the firewall to the
client hosts) cannot be expressed as a variable with a fallback. If the host list
does not resolve, the play has to stop. Otherwise the narrowing is only as strong
as a lookup succeeding, and its failure mode is the thing it was meant to prevent.
mrveiss commentedon Sep 27, 2026
#16627 is not a hard blocker — the server side is assignable today, through a different key
Read-only, from
origin/main. This corrects my own earlier note on this issue,which called the
vault_redis_passwordorphan "the precondition for the credentialhalf". It is a precondition for one of three renderers, not for the change.
Q1 — is the server side genuinely unassignable?
No. There are three renderers that can emit
requirepass, and they do notshare a variable:
requirepasskeyed onroles/redis/templates/redis-stack.conf.j2:79-80redis_password←vars/main.yml:22←vault_redis_password{% if %}is falseplaybooks/deploy-native-services.yml:98autobot_redis_password, else envAUTOBOT_REDIS_PASSWORDplaybooks/deploy-hybrid-docker.yml:144vault_redis_passwordhas 5 occurrences in the whole tree and 0 producers —every one is a consumer or a comment, and there is no ansible-vault file anywhere.
So the role path's server credential is wired to a variable nothing assigns.
But
AUTOBOT_REDIS_PASSWORD— the same key the client side already reads —does set
requirepasson the two paths that ship. So the credential already existsand already flows; the role path simply does not consume it on the server side.
#17500 is self-contained if it uses
AUTOBOT_REDIS_PASSWORD. It needs #16627only if it insists on the
vault_route. #16627 is a wiring blocker for onerenderer, not a blocker for this issue.
One precedence trap, because it makes the obvious fix wrong:
roles/redis/defaults/main.yml:10setsredis_password: "", androles/redis/vars/main.yml:22sets it to{{ vault_redis_password | default('') }}.Role
vars/outranks roledefaults/, so the orphan is what takes effect. Afix applied to
defaults/main.ymlwould change nothing and would look correct.Q2 — what the client already does: it sends a password the server ignores
Yes, and this is the good news.
_shared/tasks/read_redis_password.yml:36-46grepsAUTOBOT_REDIS_PASSWORDout ofthe install-time secrets file on the control node.
roles/redis/tasks/main.yml:246then adopts it into
redis_client_password— but only when the probe is non-emptyand the client credential is still empty (
:249-250).defaults/main.yml:16isredis_client_password: "{{ redis_password }}", so absentadoption the client follows the server.
The split is deliberate and guarded, not an oversight.
tasks/main.yml:236-238:and
tests/test_redis_password_provisioned_16627.py:166asserts"redis_client_password" not in server.So on any node where the secret exists: clients authenticate, the server accepts
anything. The change is the smaller one — the server starts requiring what clients
already send.
That existing test does not block the fix, which is worth stating so nobody
reads it as one: the correct fix sets
redis_password(the server variable) fromthe shared source. It does not put
redis_client_passwordinto the server template,which is the only thing
:166forbids.Q3 — ordering: two parts, not three
requirepasson the role path, keyed toAUTOBOT_REDIS_PASSWORD, plusthe bind/firewall narrowing — one change. These cannot be separated: the
narrowing without the credential leaves the exposure, and the credential without
the narrowing is the only control on a wildcard bind.
#16627 is not in the sequence unless the
vault_route is chosen deliberately.But one thing does have to be closed in the same change, or the fix has a
bypass. Both shipping renderers guard
requirepasson(env AUTOBOT_REDIS_PASSWORD and not env AUTOBOT_REDIS_USERNAME). Set a usernameas well as a password and
requirepassis skipped — and nothing in the treecreates an ACL user to take over:
group_vars/database.ymlhassecurity.redis_security.acl_enabled: falseaclfiledirective istemplates/redis/redis-stack.conf.j2:132playbooks/deploy-database.yml:135templatesredis-acl.conf.j2, and thattemplate does not exist in the tree — the task is only reachable when
acl_enabledis true, so today it is latent rather than brokenSo password + username together produce a store with no authentication at
all, by design of the conditional. A repair that only adds
requirepassinheritsthat hole.
Where the #17642 guard goes
Not a defaulted variable — that is the whole finding. Proven at
community.general9.5.2ufw.py:405: an emptyfrom_ipdrops thefromclauseentirely,
rc 0, no warning, rule applies from any source.or ''fallback fromroles/redis/defaults/main.yml:7.assertinroles/redis/tasks/main.ymlimmediately before the two ufwtasks at
:175and:186, requiring the source list to be non-empty, so anunresolved value stops the play instead of widening the rule.
roles/common/tasks/main.yml:161already does withdefault('any')— neverinherited from a failed lookup.
Two sightings that belong to existing issues, not here
Both found while answering the above; recording where they go rather than
re-diagnosing them:
playbooks/deploy-database.yml:123-125renderstemplates/redis/redis-stack.conf.j2to{{ redis_config_dir }}/redis-stack.conf,and
deploy-database.yml:17setsredis_config_dir: "/etc/redis-stack"— adirectory one character away from the canonical
redis_config_file: "/etc/redis-stack.conf"(group_vars/all.yml:53). So it writes where the daemonnever looks. It is on the deploy path (
deploy.sh:273,deploy-full.yml:110).→ sighting for security(redis): the role's Redis Stack config is never read — requirepass is empty on a provisioned node, and one path has four spellings #17434 (inert path) and fix(redis): two renderers write one Redis config for the same host group, and only one emits the TLS block #17441 (renderer count; fix(redis): two renderers write one Redis config for the same host group, and only one emits the TLS block #17441
inventories two, and its AC-2 guard watches the canonical path, so it would not
see this one).
templates/redis/redis-stack.conf.j2:128isrequirepass {{ redis.password }}under a guard that is true(
auth_enabled: true), but theredis:dict atgroup_vars/database.yml:66-105has no
passwordkey — the password is defined atsecurity.redis_security.password:37, a different name. Noredis:dict anywherein the inventory defines
password,ansible.cfgsets nohash_behaviour, anderror_on_undefined_varsis at its default. Same family as the rest of thisissue: the value exists, correct and complete, under the name nobody reads.
group_vars/database.yml:73-75hasredis.bind: "0.0.0.0"andprotected_mode: falsein tracked group_vars — → security(redis): the rendered config is open by default — wildcard bind, protected-mode off, and a requirepass that is silently omitted #17551. Noting it onlybecause it means the wildcard bind is configured intent in this repo, not merely a
vendor default.
Refs #16627, #16628, #17434, #17441, #17551, #17572, #17642
mrveiss commentedon Sep 28, 2026
Investigation and repair proposal — written only, nothing changed
No host state, config or credential was modified. Every probe below is read-only.
Correction: the compose file is not what is running
The title attributes the exposure to
docker-compose.yml. It is not that, on this deployment: the container runtime is not installed in the environment the service runs in, so no compose file is in effect. Redis runs as a native systemd unit (redis-stack-server.service, active), started from the package's own config file.The compose file does contain the same defect — its Redis port mapping has no bind address while the RedisInsight mapping directly beneath it is pinned to loopback — so it would reproduce this wherever compose is used. But repairing only the compose file would change nothing here, and that is worth knowing before anyone writes the fix.
The exposure, as measured
Three layers are open independently, and a firewall rule permits the traffic:
bind 0.0.0.0in the service's package config;ssshowsLISTEN 0.0.0.0:6379protected-mode noin the same fileredis-cli pingreturnsPONGwith no credentials[10] 6379/tcp ALLOW IN <a routable /24>, in a table of 48 rules, default policydeny (incoming)protected-mode nomatters more than it looks. Protected mode is Redis's own fallback: with no password set it refuses non-loopback connections. It has been turned off, which removes the guard that would otherwise have made the open bind harmless.The full table was read, not a head of it. The earlier 4-rule reading that shaped this issue came from a truncated view; the table is 48 rules and rule 10 is the relevant one.
Auth is available and unconfigured, not absent
This determines the repair. The client side already supports credentials:
AUTOBOT_REDIS_PASSWORDandAUTOBOT_REDIS_USERNAMEare both registered env vars with client support (the username path landed in #16626). Nothing needs building — the server simply has norequirepassand the services pass no credential.Who actually depends on the reachability
This is the finding that changes the risk of the repair. Live client list: 26 connections across 2 distinct addresses — loopback, and one of this host's own interfaces. No remote machine is connected.
So the
/24allowance currently serves nothing. It can be removed without breaking any peer that exists today. What it does mean is that ~22 connections reach Redis via the host's own routable interface rather than loopback, so a bind narrowed to loopback alone would break them.The repo is the source, and it is currently broader than the running state
autobot-infrastructure/shared/config/ufw-defaults.sh:108ufw allow 6379/tcp— no source restriction at allautobot-infrastructure/shared/config/redis-optimized.conf:29-30bind 0.0.0.0,protected-mode noautobot-infrastructure/autobot-database/templates/redis.conf:5,7bind 0.0.0.0,protected-mode noNeither template sets
requirepass.The live rule is narrower than the repo's. Running state restricts 6379 to one
/24; the repo would allow it from anywhere. So re-running the provisioning script widens the exposure. That inverts the usual assumption that the repo is the safe state to converge on, and it means the repo change is required before any re-provision, not merely desirable.Adjacent, same shape, not fixed here: the same file allows
5432/tcpand8001/tcpwith no source restriction. This is a class in that script, not one rule.Proposed repair — two phases, in this order
Phase 1 — close the reach. No credentials involved, no outage risk.
6379/tcpallow fromufw-defaults.sh.protected-mode yesand narrowbindto loopback plus the internal interface the local services actually use.ssno longer shows a0.0.0.0bind for 6379; the existing connections survive; an off-host connection attempt is refused.Phase 1 is safe because no remote peer exists. It is also reversible by the same path.
Phase 2 — authentication. Scheduled window, human-approved.
requirepasssourced from that secret.What breaks if the order is wrong, which is the part that needs stating: Redis rejects
AUTHwhen no password is set, so clients cannot be pre-configured against the current server — the credential cannot be rolled out ahead of the server change. Server and clients therefore have to change inside one window. Server-first with clients unaware is a total outage of backend, worker and SLM paths, because Redis is load-bearing for all of them. Clients-first is an immediate error on every connection. If zero-downtime is required, the ACL route — add a credentialed user alongside the existingnopassdefault, migrate clients, then removenopass— achieves it at the cost of a three-step migration.What I could not establish
AUTOBOT_REDIS_HOSTdefaults to loopback, so something overrides it. Until that is found, step 2 has to keep the internal interface in the bind list; narrowing to loopback alone would break those clients. This is the one item that could turn Phase 1 from zero-risk into an outage, and it should be resolved before the change is written./24is reachable beyond this host in practice. I observed the firewall rule and the absence of remote clients; I did not test reachability from another machine, which is the only way to confirm the exposure is live rather than latent.ufw-defaults.sh. This matters because it determines whether the repo fix is sufficient or whether a second mechanism also writes this rule.mrveiss commentedon Sep 28, 2026
Addendum: the blocking gap is resolved, and Redis is the only one of the three ports with every layer open
Still read-only. No host state, config or credential changed.
1. Why ~22 connections arrive over the routable interface — resolved
The deployed backend's environment file sets the Redis host to the host's own routable interface address, not loopback:
Traced from the connections rather than assumed: the established sockets belong to
uvicorn(the backend unit) and apython3.14worker, and their source address is routable — which only happens when the destination is too. A connection to loopback has a loopback source.So the backend talks to a local Redis by going out to the host's own routable address and back in. That is the whole reason the bind cannot be narrowed to loopback alone.
Two corrections this forces:
autobot-slm-backend.servicesets the host to loopback explicitly, in its unitEnvironment=. So the SLM path is already correct and only the backend and worker take the routable route. My earlier framing implied one uniform misconfiguration; it is one service's env file.infrastructure.yamlanddistributed.yamlboth carryhost: ""with the comment "Set via AUTOBOT_REDIS_HOST env var at deploy time". The routable value is injected by whatever generates the deployed.env, so the repo is neutral here and the repo-side fix for Phase 1 is not in those files.What this does to Phase 1. There are now two orderings, and the cheaper one is available because no remote peer exists:
A is the one I would put to the user, because B's end state still depends on the firewall being the only thing in the way.
2. Whether remote reachability is intended, not just unused
No repo config points a remote host at this Redis — there are no non-loopback
redis://URLs in the infrastructure config outside templated placeholders.One piece of contrary evidence worth stating rather than burying: an alerting rule refers to "Redis service on VM3", which implies a multi-VM topology where Redis sits on its own node and other nodes reach it. That is a design intent, not a live dependency — nothing remote is connected, and nothing in config points anywhere remote. But it means Phase 1's safety rests on the current single-node reality, and if the multi-VM layout is ever realised the loopback-only end state would have to be revisited deliberately rather than discovered.
This is also the honest answer to my earlier fourth gap: a live client list cannot see a scheduled job or an offline node. What I can now say is that no configuration expects a remote consumer, which is stronger than "none is connected" and weaker than "none is intended".
3. The adjacent ports — Redis is the only one with all layers open
protected-mode noPINGanswers unauthenticatedpg_hbarefuses an unconfigured user even on loopbackSo the answer to whether these are one problem: they are not. PostgreSQL has the same firewall exposure and a working authentication layer behind it, which makes it a hardening item rather than an open door. Redis is the only one where the port, the fallback and the authentication are all open at once.
Gap, and I am not closing it: I could not characterise the backend API's authenticated surface. Three probes (
/openapi.json,/docs,/api/auth/me) returned no response within 6 seconds rather than a status code, so I have one data point — a health endpoint that is public by design — and no basis to say whether authenticated routes are properly gated. That needs its own look and should not be folded into this issue's conclusion either way.mrveiss commentedon Sep 28, 2026
Provenance of the readings above — and the update is mid-flight, not finished
A deployment update is in progress as I write this. Flagging it before anything in this issue gets read as current, because one of my numbers is load-bearing and it is the one a restart invalidates.
Every live reading above was taken before 07:08 EEST on 2026-09-28
bind 0.0.0.0andprotected-mode noin the service config/24redis-cli pinganswering unauthenticatedThe last row is the entire basis for "Phase 1 is safe because no remote peer exists." It deserves the caveat more than the rest.
The update is partially applied, which is worth more than either endpoint
Unit start times, read at 07:09:06 EEST:
So the synced tree matches
origin/mainwhile two of three services are still running pre-update code. That is exactly the shape #16310 describes, observed rather than inferred: a current SHA is not a completed update. Any conclusion drawn from this host in the next few minutes has to say which half it is about.What the partial state already tells us, for free
One service restarted and reconnected while Redis stayed up, so this is a natural experiment on the load-bearing number. Client list at 07:09, mid-update:
Still two address classes, no remote peer — and the loopback group is intact at 4 after the SLM service restarted into it. That is consistent with
autobot-slm-backend.servicesetting the Redis host to loopback in its unit environment, which I reported earlier from config: the restart confirms it behaviourally rather than by reading a setting.So the safety argument for Phase 1 has now survived one service restart. It has not survived a Redis or backend restart, which is where the remaining risk to that number sits.
Correction to a question I had left open
I had listed "why do ~22 connections arrive over the routable interface" as unresolved, and suggested watching the reconnection to find the override. That is already answered in the addendum above: the deployed backend environment file sets the Redis host and URL to the host's own routable address. The restart-watching instrument would confirm it but is not needed to find it.
What the restart is needed for is a different question: whether this update rewrites that env file. If the new deployment regenerates it with the same routable value, the Phase 1 repoint has to be made where the file is generated, not in the file. If it regenerates it with loopback, part of Phase 1 has already happened by accident and should be verified rather than repeated.
What I will re-measure once services have settled, and what I will not
Re-measure: the client list and its address classes, and the backend env's Redis keys.
Not re-measure: the firewall table and the config-file contents — but I will re-check them rather than assume, precisely because this issue started from a truncated firewall read and the cost of assuming was the shape of the original report.
No host state, config or credential has been changed at any point in this investigation, and nothing about the update is mine to drive.
mrveiss commentedon Sep 28, 2026
Re-measured after the update completed — the load-bearing number held, and the update introduced a contradiction
Read at 2026-09-28 07:34 EEST, deployed tree at
7adaca8c296d. Read-only throughout.What restarted
Redis itself never restarted, so the entire connection set was rebuilt by clients reconnecting. That is the cleanest possible re-measurement of the number this issue's safety argument rests on.
The load-bearing finding survived a full backend restart
Identical to the pre-update reading: two address classes, no remote peer. The backend went down, came back, and re-established 22 connections from the same single routable address. So "Phase 1 is safe because no remote peer exists" is now a claim that has survived a restart of every client, rather than a snapshot of one moment.
The firewall table and service config, re-checked rather than assumed
Both unchanged:
bind 0.0.0.0andprotected-mode nostill in the service config,PINGstill answers unauthenticated, 48-rule table with rule 10 still allowing the port from a routable/24. I re-read them instead of reasoning that the updater does not touch them, because a truncated read of exactly this table is what gave the original report its shape.The update rewrote the env file and left it self-contradictory
The backend env file was rewritten at 07:17:49, inside the update. Its Redis keys now disagree with each other:
One key says routable, the other says loopback, for the same fact.
And the behaviour settles which one is in effect: 22 connections still arrive from the routable address after the restart, so the loopback URL changed nothing.
The reason is in the config model, and it is the sharper half of this finding:
MiscConfig.redis_urlis a field readingAUTOBOT_REDIS_URL.config.redis_urlis a property that ignores it and recomposes the URL:f"redis://{self.vm.redis}:{self.port.redis}"— that isAUTOBOT_REDIS_HOSTandAUTOBOT_REDIS_PORT.Consumers use the property (
service_config.py:270, and others). SoAUTOBOT_REDIS_URLis read into a field that the effective URL does not come from.startup_validation.py:40lists it as a validated variable with the comment "composed by Ansible from backend_redis_host/port" — so it is composed from the same inputs, stored, validated, and then not used for the connection.Why this matters beyond tidiness: the key that looks authoritative is the one spelled as a URL, validated at startup, and now pointing at loopback. Anyone reading this env file to answer "how does the backend reach Redis?" gets loopback, and is wrong. The update moved the file from agreeing and wrong to disagreeing, with the wrong half winning, which is harder to notice than either.
That is the same shape as the rest of this issue: two expressions of one fact, and the one that reads as the answer is not the one in effect.
What this does to the repair
It resolves the open question from my addendum. The Phase 1 repoint must change
AUTOBOT_REDIS_HOST, at the point the env file is generated — the update demonstrably regenerates this file, so an edit to the file is overwritten on the next update. ChangingAUTOBOT_REDIS_URLalone does nothing, which the update has now proved experimentally.Two items to add to the repair, neither of them mine to apply:
AUTOBOT_REDIS_URLis validated at startup but not used for the connection. Either it should be consumed or it should not be validated as though it governs anything.Still open
autobot-workeris inactive. It was one of the processes holding Redis connections before the update. Whether that is expected post-update or a casualty of it is not something I can tell from here, and it is outside this issue — flagging it because it changes what the connection census represents.mrveiss commentedon Sep 28, 2026
Correction: my "self-contradictory env file" finding was wrong, and the cause was my own redaction
Retracting the central claim of my previous comment. It is wrong, and the mechanism is worth stating because it is the same class as the defects this issue is about.
What I claimed, and why it was false
I reported that the update left the backend env internally inconsistent — the Redis host routable, the Redis URL loopback. Compared programmatically rather than by eye:
The file agrees with itself. Both point at loopback.
The error was in my own redaction. I was masking addresses before displaying them with a substitution that rewrote
KEY=<address>but did not touch an address appearing inside a URL value. SoHOST=127.0.0.1was displayed asHOST=<routable addr>while the URL's127.0.0.1passed through untouched — and I then read the two lines as disagreeing.I built an instrument that transformed one of two values I was comparing, and compared the transformed one against the raw one. Every conclusion in that section followed correctly from a difference my own tooling introduced. That is precisely the family this issue keeps producing: the measurement was fine, the thing being measured was not what I thought.
What actually happened, which is better news than what I reported
The update fixed the host. Before it, the backend env pointed Redis at the host's routable address; after it, loopback. I reported that as a regression when it was the opposite.
The real defect is one of ordering, and it is still real:
The service restarted, then its configuration was regenerated. So the running process kept the pre-update routable host, and the corrected file sat on disk unread. My 07:34 census — 22 connections from the routable address — was measuring a process that predated its own config by 26 seconds.
Confirmed by the manual restart at 07:42:22, after which the census flips:
So the corrected configuration took effect only on a restart that happened to come later for other reasons. Without it, the fix would have sat inert until the next restart, with the file and the process disagreeing and the file looking correct.
What this does and does not say about the update path
Does not: the
update-all-nodes.ymlplaybook orders env regeneration before the backend restart, correctly. That ordering is not the bug.Does: whichever path ran this update restarted the backend before regenerating its env. I have not identified which path that was — I was searching for it when this was interrupted, and I am not going to name a culprit I have not found. What is established is the observable: a service restarted 26 seconds before the config it reads was written.
That is the "does it need more steps" answer, and it is two steps rather than a rewrite:
validate-env-files.pybetween generation and restart. It already encodes the URL/host/port consistency invariant at:154, and a repo-wide grep finds nothing that invokes it. A written check that nothing runs. It would not have caught this defect, since the file was consistent — but it is the natural gate for the class, and it currently protects nothing.Still open, unchanged
bind 0.0.0.0andprotected-mode noare all unchanged, re-read rather than assumed.mrveiss commentedon Sep 28, 2026
Second correction: there is no ordering bug. The defect is a verb.
My previous comment said the update restarted the backend before regenerating its env. That is wrong, and so was the reasoning behind it. Traced properly from the ansible log.
What the log actually shows
Three ansible runs were interleaved, which is what my "26 seconds later" comparison silently spanned:
The 07:17 events came from the second playbook, not the first. I compared a restart from one run against an env write from another and read a causal ordering into it. Both runs order their own steps correctly.
The real cause, and it is one word
Within the backend role the sequence is right:
roles/backend/tasks/main.yml:1501:state: startedis a no-op on a service that is already running. The env is written, the task succeeds, and the process keeps the configuration it started with. Confirmed on the host: the backend's start time did not move across the entire post-env sequence, and the corrected Redis host only took effect on a later manual restart.The
Always-restart guard so backend can't be left dead (#12139)at:1521is alsostate: started, so despite its name it cannot restart anything either.So two tasks assert in their names that they apply an env change or restart a service, and both use a verb that cannot do it. They pass because
startedsucceeds — which is why this has been invisible.This is the same class as the rest of this issue: the name states the intent, the mechanism cannot deliver it, and success is reported either way. It is the third instance I have produced or found on this issue alone.
Consequence
Every env change applied by this role requires a separate manual restart to take effect, silently. Anyone verifying a config change by reading the deployed file sees the new value and concludes it is live. It is not, until something else restarts the service.
The repair
state: restartedon the post-env task, or condition a restart on the env template reportingchanged. Plus a test asserting that the task following the env deployment uses a config-reloading verb — nothing asserts that today, which is why astartedcould sit in a task named "Start service after env update" indefinitely.Also still standing, independent of this:
validate-env-files.pyencodes the URL/host/port invariant at:154and a repo-wide grep finds nothing that invokes it. It would not have caught this defect, but it is the natural gate for the class and currently protects nothing.Not yet fixed — the diagnosis is complete, the change is not written. It needs its own branch, and I am flagging it rather than claiming it done.
mrveiss commentedon Sep 28, 2026
Third correction, and this one would have caused an outage: retracting the
state: startedfinding entirelyDo not apply the one-word fix I proposed.
state: startedatroles/backend/tasks/main.yml:1501is correct and deliberate. Changing it torestartedwould undo a documented decision and re-open a known outage mode.What I failed to read
:477— 1,024 lines above the task I quoted, inside the same block, unconditional, identically tagged:And the rationale, at
:474:And at
:345, why thealways:guard is alsostarted:So the stop and the start are one restart, split across the role on purpose.
startedis not a no-op there because the service is stopped. Thealways:guard isstartedbecause it is a floor, not a reload. My proposal would have reintroduced precisely the race the split exists to prevent, and it was published as the answer to a direct question about the update path.The pattern, stated plainly
This is the third instance of the same error in this one investigation:
All three are the same shape, and it is the shape this issue is nominally about: a mechanism read without the context that determines what it does. The first two produced wrong findings. The third produced a wrong change, which is categorically worse — a wrong finding wastes a reader's time, a wrong change to a deploy path costs an outage.
I had the general rule available and applied it elsewhere in this same investigation — I checked whether
_scan_fileblanked comments before trusting a test, I checked the full firewall table rather than a head of it. I did not apply it to a line I had already decided the meaning of.What remains true, and it is smaller
My observation stands and my explanation of it does not: the backend's start time read 07:17:23 and did not move across the post-env sequence that ran at 07:17:49-53. Under a stop-then-start pair it should have moved to ~07:17:51. I do not know why it did not, and I am not going to offer a third theory. Candidates I have not checked: a tag-filtered run skipping both halves, the
failed_when: falsestop silently not stopping, or the interleaved-runs confusion for a third time.The one gap worth filing, which is not what I claimed
Nothing asserts that the stop at
:477and the start at:1501carry matching tags. They do today — both['backend', 'config']. If they ever diverge, a tag-filtered run stops the backend and never starts it, which the MVA-1633 comment says the start task exists to prevent. Thealways:guard covers a task failing inside the block; it does not cover the block's tasks being filtered out.That is a real, small, checkable invariant: a test that the stop task's tag set equals the start task's. It is worth filing. It is not the defect I reported.
validate-env-files.pybeing uninvoked also still stands, unrelated to any of this.mrveiss commentedon Sep 28, 2026
Chunking triage — a proposal, not an assignment
priority: critical— not applied. Setting it is the milestone owner's call.origin/mainsince 2026-08-14 references this issue.Nothing was relabelled, moved or closed by this pass.
mrveiss commentedon Sep 28, 2026
B5 spec — first in the batch, and the fix is already written one line below the defect
Batch B5 = #17500 → #17635 → #17642, one risk class (a service reachable from more of the network than intended), one review pass. Full spec at
~/b5-spec-2026-09-28.md.Target:
docker-compose.yml:48-50— the two lines are adjacent and only the second is bound:Change: bind the data port the way the neighbour already binds the UI, and where a non-loopback bind is genuinely needed make the address a variable with a loopback default rather than an empty prefix.
Test file:
repo_tests/compose_ports_are_bound_17500_test.py(new — there is no compose guard today). Copyrepo_tests/chromadb_bind_not_hardcoded_15317_test.pyrather than starting fresh: it already carries the wildcard-bind vocabulary (_WILDCARD_BIND_VALUES = {"0.0.0.0", "::", ""}) and both-directions negative controls, and it is the same property one layer down.Assertion: no
ports:entry publishes without an address prefix, except entries on an explicit allow-list with a recorded reason.Mutation: drop
127.0.0.1:from the RedisInsight line → red. Negative control: an allow-listed public entry must pass, or the guard cannot describe a deliberate one.