Skip to content

Releases: heyvaldemar/chatops-privilege-wall

v1.3.1

Choose a tag to compare

@heyvaldemar heyvaldemar released this 26 Sep 16:51

Changed

  • The read timeout also answers to TRAEFIK_READ_TIMEOUT, the name every
    Traefik stack in the fleet takes. When set it wins; WALL_REQUEST_TIMEOUT
    keeps working, and with neither set the default is still 60s.

v1.3.0

Choose a tag to compare

@heyvaldemar heyvaldemar released this 26 Sep 16:31

Added

  • The write and idle timeouts on Traefik's HTTPS entry point can be set from .env.
    TRAEFIK_WRITE_TIMEOUT and TRAEFIK_IDLE_TIMEOUT join WALL_REQUEST_TIMEOUT,
    which already set the read timeout, and default to Traefik's own values (0s,
    180s), so nothing changes unless you set them. The same variables now exist in
    every Traefik stack in the fleet, after a user of the
    Keycloak template
    asked for a way to tune them without replacing the whole command.

Changed

  • The freshness check has its own workflow, Pin Freshness. It ran inside Deployment Verification, whose badge is the one at the top of this README. Across the fleet, nine red runs in ten were a pin one version behind - which the fleet's triage moves within the day - and a reader cannot tell that from a stack that does not boot. The badge now says whether the stack boots. The job itself is unchanged.

v1.2.3

Choose a tag to compare

@heyvaldemar heyvaldemar released this 22 Sep 12:02

Security

  • python:3.13-alpine was rebuilt upstream; the pin moved from sha256:1a63a53928ce… to sha256:79e7a9b9ff1c…. Same version, same tag, a rebuilt base image — the usual shape of a security fix in a base layer.

Upgrading

git pull (or ./update.sh), then docker compose up -d. Containers on a refreshed image are recreated; data volumes and .env are untouched. This release was cut by fleet triage after the deploy job booted the stack on the refreshed images.

Full history in CHANGELOG.md.

v1.2.2

Choose a tag to compare

@heyvaldemar heyvaldemar released this 21 Sep 18:27

Security

  • traefik:3.7 was rebuilt upstream; the pin moved from sha256:1c32e7c36820… to sha256:24841fe2de73…. Same version, same tag, a rebuilt base image — the usual shape of a security fix in a base layer.

Upgrading

git pull (or ./update.sh), then docker compose up -d. Containers on a refreshed image are recreated; data volumes and .env are untouched. This release was cut by fleet triage after the deploy job booted the stack on the refreshed images.

Full history in CHANGELOG.md.

v1.2.1

Choose a tag to compare

@heyvaldemar heyvaldemar released this 18 Sep 15:46

Security

  • python:3.13-alpine was rebuilt upstream; the pin moved from sha256:7415fbc3c9e4… to sha256:1a63a53928ce…. Same version, same tag, a rebuilt base image — the usual shape of a security fix in a base layer.
  • traefik:3.7 was rebuilt upstream; the pin moved from sha256:f86a2cab1b5c… to sha256:1c32e7c36820…. Same version, same tag, a rebuilt base image — the usual shape of a security fix in a base layer.

Upgrading

git pull (or ./update.sh), then docker compose up -d. Containers on a refreshed image are recreated; data volumes and .env are untouched. This release was cut by fleet triage after the deploy job booted the stack on the refreshed images.

Full history in CHANGELOG.md.

v1.2.0

Choose a tag to compare

@heyvaldemar heyvaldemar released this 13 Sep 15:44

A health check that knows itself from an incident.

A privileged endpoint nobody probes is one you find out about on the day it matters, and this one cannot be probed by doing anything: every action it can perform is an action nobody asked for. So canary.sh probes it by being refused, twice, at the two gates that must never stop working.

The first probe uses an id that is not on the operator list. The second uses an id that is on the list with a token nobody issued, which means it has to pass the operator list to reach the gate it tests.

ACTION_URL=https://your-host/act ./canary.sh

Both refusals are the check passing

So the worker logs them and does not announce them. Two messages a day saying nothing happened is how a channel stops being read, and a channel nobody reads is where the refusal that mattered goes to die. The skip prints its own log line, so the path taken is visible rather than assumed.

CANARY_ID_PREFIX decides what counts as a probe. It is a prefix and not an id, so the probe identities stay in canary.sh instead of being copied into configuration to drift apart from it.

Write the canary's id so nobody can mistake it for a person

This is not style. In the system this pattern comes from, the canary's entry looked exactly like a colleague's id and sat under a comment naming three people. During a tidy-up the whole list was read as stale and the entry was deleted with the genuinely dead ones. The health check broke that night, and the first anybody saw of it was two refusal cards at one minute past midnight.

So the canary now names that failure precisely instead of reporting a vague one: "is not in ALLOWED_USER_IDS, so this probe stopped at the operator list and never reached token validation." Verified by removing the id and watching it say exactly that.

A junk argument is rejected with exit 2 rather than ignored, because ignoring it runs a probe nobody asked for and then reports success for it.

Two scenarios that are really one test

The canary's refusal is not announced, and an ordinary refusal still is. Proving only that the canary is quiet cannot tell a working exemption from a notifier that has stopped working altogether. Eighteen scenarios in CI now.

Upgrading: ./update.sh. Add your canary's operator id to ALLOWED_USER_IDS before you run canary.sh from a timer.

v1.1.0

Choose a tag to compare

@heyvaldemar heyvaldemar released this 13 Sep 15:44

Moving between releases, and a CI run that upgrades rather than starts fresh.

A deployed host had no way to move between release tags, and CI proved only that the current release boots on empty volumes. Neither says anything about what a deployed host actually does, which is git pull && docker compose up -d over a token store and a queue the previous release wrote.

./update.sh refuses to cross a major version unattended, refuses to run over local changes, and names any variable that became required since your version before anything has moved. ./update.sh --dry-run says what would happen.

It waits for the worker to report healthy rather than stopping at up -d. The worker is judged by its queue loop, and one that comes back unable to read its queue leaves every button silently doing nothing while the exit code says success.

CI now starts the previous release on this project's volumes first, waits for it to be healthy, then stops it keeping the volumes, so the up -d that follows is an upgrade and every check after it judges an upgraded stack.

Both gaps were found by fleet conformance, which had never looked at this repository: its selector is a name rule, and this name does not end in -docker-compose.

Upgrading: git pull && docker compose -f chatops-privilege-wall-docker-compose.yml up -d, and ./update.sh from then on.

v1.0.0

Choose a tag to compare

@heyvaldemar heyvaldemar released this 13 Sep 15:44

A chat button that restarts a service, without giving the internet root.

The usual way to build this is one container. It listens for the click and it holds a Docker socket, so the most exposed component you run is also the most privileged one, and the only thing between those two facts is a small JSON handler you wrote on a Tuesday.

A Docker socket is not a way to run containers. It is root on the host: anything holding it can start a container that mounts /. Mounting it read-only changes nothing, because the API is the same API in either direction.

This is that system with a wall down the middle.

The shape

The edge listens and can do nothing else. No Docker socket, no host mounts, no credentials, a read-only filesystem, every Linux capability dropped, and a network declared internal: true so it has no route out at all. Its entire vocabulary is writing a file into one directory.

The worker reads that directory, judges, and acts. It holds whatever privilege the action needs and it has no inbound path: nothing published, no port, and it is not on the network the edge is on.

The two share one volume and nothing else. A directory is the channel on purpose, because there is nothing in a directory that can carry more authority than a filename.

Only declared fields cross. FORWARD_FIELDS names them, each is stringified and truncated, and everything else in a request stops at the edge. A request names an action from a table in worker/actions.py; it never carries a command.

Five things that cost an evening each

An allowlist that is empty must mean nobody. Written the natural way, if ALLOWED and user not in ALLOWED, an empty list stops meaning "nobody" and starts meaning "skip the check". That shipped in the system this comes from, the list was empty the whole time, and nothing had gone wrong only because nobody had tried.

A refusal has to name the id, not just the person. An action was once refused because the id recorded for an operator was not that operator's id: the account had been rebuilt and the list kept the old one. The log named the human, which is the one thing you already know.

A refusal has to be announced somewhere it can be seen. Three operators clicked against a stale allowlist, all three were refused, and all three saw nothing at all. The reply carried in an HTTP response is one copy, visible to one person, in a place the worker cannot read back.

A refused action is not a result. Buttons are spent when used so one message cannot be actioned twice, which also meant an action the host declined left a message with no buttons and a green tick. A refusal puts the buttons back.

The exit code is the answer, not the output. Reading the last line and calling it the result turns every refusal into a success.

Tested

Sixteen scenarios against a running stack, in CI. Every claim this design makes is a negative one, and a negative claim nobody tests is a comment: that the edge holds no socket, that it cannot reach the internet, that it cannot reach the worker, that an undeclared field does not cross, that a full queue answers 503 rather than filling the disk, that a half-written request is never read, and that a worker whose queue loop has stopped reports unhealthy while its container is still running and still looks fine.