Skip to content

fix(worker): the Valkey pi-dispatch starts gets a per-deployment password (#468) - #475

Merged
edgehero merged 2 commits into
mainfrom
fix/468-valkey-password
Sep 28, 2026
Merged

edgehero merged 2 commits into
mainfrom
fix/468-valkey-password

Conversation

@edgehero

@edgehero edgehero commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Closes #468

What was wrong

The Valkey pi-dispatch starts (the Quadlet unit, the compose file and up's docker run) had no password. Loopback is shared by every account on a host, so any local account could read another account's queue (task text, repository names), enqueue jobs that account's worker would run with its provider key, or wipe it. #464 stops accidents; this stops a co-tenant who connects on purpose.

Change

  • A per-deployment password, VALKEY_PASSWORD, in .env. A separate key, not inside VALKEY_URL, because doctor prints the URL, the panel's pointer stores it and refusals quote it. init writes one and creates .env at 0600; service install and up add one when missing, never over a value, and narrow .env to its owner. None is generated for a shared Valkey (PI_VALKEY_SHARED) or an operator's own VALKEY_URL, which are left as they are.
  • Never on a command line. Measured on both VMs: the image's PID 1 (tini) keeps its whole argv, so valkey-server --requirepass X shows X in /proc/<pid>/cmdline to every account for as long as the container runs, and the image's VALKEY_EXTRA_FLAGS appends to that same argv. The password reaches the container in its environment (a 0600 ~/.config/pi-dispatch/valkey.env for the Quadlet unit, --env-file .env for compose, the docker CLI's own environment for up), and one start script hands it to valkey-server - as config on stdin and unsets it. The health check requires PONG with REDISCLI_AUTH, since a bare valkey-cli ping exits 0 on NOAUTH (measured).
  • Clients. parseConnection alone attaches the credential (the URL's own, then VALKEY_PASSWORD), for every client through a second account on one host: shared default jobs dir, podman after linger off, and a half-written service install #464's one connection function. A Valkey that refuses the credential makes the worker exit 2 and the CLI, receiver and doctor refuse, naming the key. The value is never logged; VALKEY_PASSWORD joins the worker-only secrets, so it can never reach a job container.
  • Upgrade. A loopback Valkey without a password is a doctor warning naming the step. service install --force restarts the Quadlet Valkey with the password (the AOF volume is kept) and then the running worker and receiver; up does the same for docker, warning that a running job is interrupted. doctor itself starts no Valkey: by the doctor judges many service keys from its shell while the service reads them from .env #471 rule it hands no program anything from .env, so its fix line names pi-dispatch up, and its password check is in-process.
  • One owner per Valkey. A container or volume counts as this deployment's only when it provably is: up labels what it creates with the deployment folder; an unlabelled container counts only when it publishes this deployment's port; an unlabelled volume is adopted only on an explicit yes (never --yes alone), after its pi-dispatch:owner marker is read in a container with no network, and the adoption is recorded in the folder. Nothing stops, removes or starts beside another deployment's Valkey or on its volume.
  • The kill switch. The CLI and the panel read VALKEY_URL like the password. On a shell/.env disagreement pause pauses both queues, status shows both, and resume and cancel refuse until --valkey-url names one. URLs print without userinfo.
  • Compose publishes Valkey on VALKEY_URL's port (PI_VALKEY_PORT, written into .env for a non-default port). The wizard keeps its old compose project name with -p, and when up's Valkey exists it hands that volume to compose (pull first, every exit code checked, the way back named), so worker and receiver share one queue.
  • Found on the way: a password in VALKEY_URL reached the client still percent-encoded; the .env writer's temp file was world-readable until its chmod; up's docker Valkey step ignored VALKEY_URL's port; and the wizard's compose copy resolved its relative paths outside the deployment folder. All four are fixed, and the admin panel's pause and resume now follow the CLI's rule.

Specs

REQ-DEPLOYMENT-BOOTSTRAP, DES-PODMAN-STACK-AS-QUADLET-UNITS, DES-WORKER-ON-HOST, DES-FIRST-RUN-SETUP-WIZARD, INT-DEPLOYMENT-POINTER-CONTRACT; the others UNCHANGED, checked in the rows.

Verification

  • Full suite in CI posture: fail 0. 31 mutations, all killed.
  • pd-fedora (Podman 5.8.1) and pd-ubuntu (4.9.3), two fresh accounts each: a fresh install runs a job; another account's PING, KEYS and FLUSHALL get NOAUTH; no command line on the host carried the password during install or at rest; an upgrade from a passwordless deployment kept its queue (marker key and 3 paused jobs survived and completed); a shared Valkey without the password is refused by name. Compose was checked on Fedora through root's Podman socket; Ubuntu has no compose.

edgehero added a commit that referenced this pull request Sep 28, 2026
…r the wizard's receiver, the CLI's VALKEY_URL from .env (#468)

PR #475's review.

The password could be printed. ioredis hangs the failed command on every reply error, and for a
failed AUTH its args are the password; after a rotation, a Queue with no `error` listener let BullMQ
`console.error` the whole object (measured against Redis with CONFIG SET requirepass and CLIENT
KILL). connection.mjs now keeps only the command's name on every error any client of the process
emits (one prototype hook, BullMQ's clients included, before any listener or fallback sees it), and
every Queue and Worker the project builds carries a listener that writes the message alone. A test
rotates a stand-in Valkey's password under a running Queue in a child process and checks neither
stream carries either password; the gate's own reproduction against a real Redis now prints two
message-only lines and no password.

The wizard's compose answer, after the copy moved to <dir>/deploy/, ran as compose project
"deploy" for every folder. It now passes `-p <folder name>`, normalised as compose normalises a
directory name (measured with compose 2.31, including "My Deploy.v2_x" -> "mydeployv2_x"), which is
the project, and volume, the old layout's copy ran as; the legacy warning names both. Its title and
its skip hint are the argv that runs. And the wizard's receiver now uses the worker's Valkey: `up`,
earlier in the wizard, runs pi-dispatch-valkey on 6379, where compose's own valkey could not bind (or
would be a second queue the receiver fills). When up's container exists, a create-only
deploy/docker-compose.valkey.yml gives compose's valkey up's volume, pi-dispatch-valkey-data, and the
consented run stops and removes up's container before compose starts on the same data; later runs
name the override. A containerised receiver cannot reach a Valkey bound to the host's 127.0.0.1 on
Linux, which is why the queue moves to compose rather than the receiver to the host.

The CLI verbs (run, pause, resume, status, cancel) and `service restart --drain` read VALKEY_URL
from the shell alone while reading the password from the deployment .env, so from the folder of a
Valkey on another port they dialled 6379. One resolver (valkeyUrlFor, cliValkeyUrl) now reads it as
the password is read: this shell's, else the .env's, a disagreement named on stderr and never a
refusal (the kill switch must not refuse).

`up`'s offer to restart pi-dispatch-valkey with its password now says that a running job is
interrupted and that the worker and the receiver get NOAUTH until restarted, and to pause first.
`service install` already says so before it acts. Doctor's accepted-password line names where the
password came from (the environment, the .env, or VALKEY_URL).

Specs: REQ-DEPLOYMENT-BOOTSTRAP (clause 5), DES-PODMAN-STACK-AS-QUADLET-UNITS (the password bullet)
and DES-FIRST-RUN-SETUP-WIZARD amended, their #468 rows extended. UNCHANGED, checked:
INT-DEPLOYMENT-POINTER-CONTRACT, DES-WORKER-ON-HOST, CONST-RETRY-INFRA-ONLY.

Signed-off-by: Rob Boerman <robboerman@live.nl>
@edgehero
edgehero force-pushed the fix/468-valkey-password branch from 0dfe8ec to b76c3e5 Compare September 28, 2026 19:40
…word (#468)

The Valkey pi-dispatch starts (the Quadlet unit, the compose file and
up's docker run) had no password. Loopback is shared by every account on
a host, so any local account could read another account's queue, enqueue
jobs its worker would run with that account's provider key, or wipe it.

- A per-deployment VALKEY_PASSWORD in .env, separate from VALKEY_URL
  (which doctor prints and the panel's pointer stores). init writes one
  and creates .env at 0600; service install and up add one when missing,
  never over a value, and narrow .env to its owner. None is generated for
  a shared Valkey or an operator's own VALKEY_URL.
- Never on a command line: the image's PID 1 keeps its whole argv, so
  --requirepass shows in /proc to every account (measured on both VMs).
  The password reaches the container in its environment (a 0600
  valkey.env for the Quadlet unit, --env-file .env for compose, the
  docker CLI's own environment for up), and one start script hands it to
  valkey-server on stdin and unsets it. The health check requires PONG
  with REDISCLI_AUTH.
- Every client attaches it through parseConnection. A failed command's
  arguments are stripped from every error any client builds, at emit,
  silentEmit and each command's reject, and every Queue and Worker logs
  errors by message only, so a rotation never prints the password; the
  entry points print an unhandled rejection by its message.
- A Valkey that refuses the credential makes every entry point refuse by
  name; a loopback Valkey with no password is a doctor warning naming
  the upgrade. service install --force and up restart the Valkey with the
  password on its volume, warning that a running job is interrupted.
- The CLI reads VALKEY_URL like the password (shell, else .env). On a
  disagreement pause pauses both queues, status shows both, and resume
  and cancel refuse until --valkey-url names one; URLs print without
  userinfo.
- Compose publishes Valkey on VALKEY_URL's port (PI_VALKEY_PORT). The
  wizard's compose copy keeps its old project name with -p, and when up's
  Valkey exists it hands that volume to compose (pull first, every exit
  code checked, the way back named), so worker and receiver share one
  queue; up then starts compose's Valkey in such a folder.
- A Valkey container or volume counts as this deployment's only when it
  provably is: up labels what it creates with the deployment folder, an
  unlabelled container counts only when it publishes this deployment's
  port, an unlabelled volume is adopted only on an explicit yes (never
  --yes alone), and a pi-dispatch:owner marker inside the Valkey is
  checked on every start. Nothing stops, removes or starts beside another
  deployment's Valkey or on its volume. PI_VALKEY_PORT is written into
  .env for a non-default port; the admin panel's pause and resume follow
  the CLI's kill-switch rule.
- On #471's rule (doctor hands no program anything from .env): doctor
  starts no Valkey any more; its --fix offer for an unreachable loopback
  Valkey is gone, since that docker run needed the password in the
  docker CLI's environment, and the fix line names pi-dispatch up.
  Doctor's own in-process AUTH check uses the resolved password;
  VALKEY_PASSWORD and PI_VALKEY_PORT are service keys, the password a
  secret shown by name only. The panel's Valkey clients and kill switch
  read the pointer's .env through #471's one reader.
- Found on the way: a password in VALKEY_URL reached the client still
  percent-encoded; the .env writer's temp file was world-readable until
  its chmod.

Specs: REQ-DEPLOYMENT-BOOTSTRAP, REQ-OPERATOR-JOB-CANCEL,
DES-PODMAN-STACK-AS-QUADLET-UNITS, DES-WORKER-ON-HOST,
DES-FIRST-RUN-SETUP-WIZARD and INT-DEPLOYMENT-POINTER-CONTRACT amended;
the rest named UNCHANGED, checked in the revision rows.

Signed-off-by: Rob Boerman <robboerman@live.nl>
@edgehero
edgehero force-pushed the fix/468-valkey-password branch from b76c3e5 to 24fd923 Compare September 28, 2026 21:25
…cord the adoption (#468)

PR #475's round-cap re-review.

- The wizard's compose steps no longer carry a shell-exported
  VALKEY_PASSWORD: compose prefers the environment over --env-file .env,
  so an exported one became the Valkey's password (measured). The
  deployment's comes from .env, as up hands docker the deployment's.
- Adopting an unlabelled pi-dispatch-valkey-data no longer publishes a
  Valkey on another deployment's data for a second after a wrong y: up
  first loads the volume in a Valkey with --network none, no published
  port and no password, reads pi-dispatch:owner through docker exec,
  stops and removes it, and only then starts the published one. Another
  folder's marker refuses with nothing published.
- An adoption is recorded in the deployment folder,
  .pi-dispatch-valkey-volume.json (0600, create-only, never over a
  different record): the volume's name and CreatedAt from docker volume
  inspect. An unlabelled volume whose CreatedAt matches is this
  deployment's without asking, so --yes works after the first adoption;
  a volume made anew is asked about again. The wizard adopts nothing
  (it names up, which asks) and records the volume up's own container
  served when it hands it over.
- Removed the wizard's unused readValkeyKeys import.

Measured on pd-fedora (root's Podman socket): the owner check's
container has no port bindings and network mode none, and reads the
marker of an unlabelled volume; CreatedAt is present for such a volume.

Specs: REQ-DEPLOYMENT-BOOTSTRAP clause (5) and DES-WORKER-ON-HOST,
DES-FIRST-RUN-SETUP-WIZARD amended; the #468 rows extended.

Signed-off-by: Rob Boerman <robboerman@live.nl>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Valkey started by pi-dispatch has no password, so any local account can read or feed another account's queue

1 participant