Skip to content

feat(dozzle): live log viewer — the first container-logs@1 provider (0.1.0) - #160

Merged
pofallon merged 3 commits into
mainfrom
feat/dozzle
Sep 20, 2026
Merged

pofallon merged 3 commits into
mainfrom
feat/dozzle

Conversation

@pofallon

Copy link
Copy Markdown
Contributor

What

A Dozzle package, and the catalog-vocabulary change it needs to exist.

container-logs@1 has been implemented in Hola since 0.11.0-rc.1 — the redacting Docker façade, the provider grant, the install consent step, the sh.hola.* labels, the tests — and nothing has ever called it. This is the app that does.

Why Dozzle

Because it has never heard of Hola. It reads DOCKER_HOST like any Docker tool, so the entire integration is one line of manifest:

"provides": ["container-logs@1"]

and the server does the rest — injects a hola-docker-proxy sidecar running its own image, holds the socket read-only there, points DOCKER_HOST at it on every service in the deployment, and answers only GET on the container list, logs, events, a redacted inspect and the probes. The compose file here is mostly comment explaining what it deliberately does not contain: the socket mount a log viewer normally needs, which the validator rejects anyway.

That makes it the sharpest possible test of a provisioned contract, where "declared" and "actually working" are hardest to pull apart — unlike backup@1, where a provider can declare the contract and never wire its hooks (#159).

The catalog had to catch up first

schemas/manifest.schema.json and bin/validate-manifest.mjs knew only auth@1, backup@1 and push@1. The contract was undeclarable here even though the server has implemented it for three releases — provides: ["container-logs@1"] failed CI with "not a known capability contract". Both now carry it.

While adding it, accepts moved onto its own acceptableContractRef enum: container-logs@1 has implicit participation, so there is no acceptor side to opt into, and naming it in accepts is now a CI error rather than something Hola drops with a warning at runtime.

The packager README gained the two axes it never documented — declared vs implicit, brokered vs provisioned — plus a table of all four contracts. That table was checked against CONTRACTS in packages/shared/src/contracts.ts rather than written from memory.

What Dozzle can't do here, by design

Feature Needs Status
Container actions (start/stop/restart) POST /containers/{id}/… disabled — DOZZLE_ENABLE_ACTIONS=false
Shell into a container POST /exec disabled — DOZZLE_ENABLE_SHELL=false
Per-container CPU/memory stats GET /containers/{id}/stats outside the grant — UI degrades
Host info GET /info outside the grant — UI degrades

Both writes are switched off explicitly rather than left to 403 in the UI. The last two are the first real finding about where that allowlist sits, and worth a decision once this has run on a VM: stats are benign resource metrics, while /info leaks host kernel/OS/registry detail and probably should stay blocked or be redacted like inspect is.

Identity

auth.mode: forward-auth, so Authentik gates the door — and Dozzle's own forward-proxy provider is pointed at Authentik's identity headers (X-authentik-username / -email / -name; Dozzle's defaults are Authelia's), so the person who signed in is the person Dozzle shows rather than everyone sharing one anonymous session.

The README is blunt about the blast radius: anyone who can open this app can read every app's logs, and logs contain more than their authors intend. That is what the install-time consent is for.

Verification so far

  • node bin/validate-manifest.mjs — all 18 manifests OK, including dozzle.
  • Hola's own validateComposeDocument against the compose — no errors, no warnings.
  • Every DOZZLE_* variable used was checked to exist upstream rather than assumed.
  • Image pinned by digest: ghcr.io/amir20/dozzle:v11.1.0@sha256:7c4fb7f8… (linux/amd64, arm, arm64).

Not yet done: a real install. Next step is a disposable VM — install with --grant container-logs@1, confirm the sidecar appears and DOCKER_HOST is rewritten, confirm logs from other apps stream, and record exactly which calls the allowlist refuses. I'd rather land that evidence before this merges.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh

Paul O'Fallon and others added 2 commits September 20, 2026 00:19
`container-logs@1` has shipped in Hola since 0.11.0-rc.1 — the redacting Docker
façade, the grant, the consent step, the `sh.hola.*` labels, the tests — and
nothing has ever called it. This is the app that does.

Dozzle is a good first provider precisely because it knows nothing about Hola.
It reads `DOCKER_HOST` like any Docker tool; declaring `provides:
["container-logs@1"]` is the entire integration, and the server does the rest:
injects its own `hola-docker-proxy` sidecar holding the socket read-only,
points `DOCKER_HOST` at it, and answers only GET on the container list, logs,
events, a redacted inspect and the probes. The compose file is mostly comment
explaining what it deliberately does NOT contain — no socket mount, which the
validator would reject anyway.

The catalog vocabulary had to catch up first: `schemas/manifest.schema.json`
and `bin/validate-manifest.mjs` knew only `auth@1`, `backup@1` and `push@1`, so
the contract was undeclarable here even though the server has implemented it
for three releases. Both now carry `container-logs@1`, and `accepts` is split
onto its own `acceptableContractRef` enum so naming an implicit contract as an
acceptor is a CI error rather than something Hola silently drops.

Two Dozzle features are disabled rather than left to fail: container actions
and shell both need Docker API writes the grant withholds. Stats and `/info`
are outside it too, so the UI degrades to logs without resource graphs — the
first real finding about where that allowlist sits.

Identity comes from the gate: `auth.mode: forward-auth` with Dozzle's
`forward-proxy` provider reading Authentik's headers, so the person who signed
in is the person Dozzle shows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh
A release version only publishes on merge, so the package cannot be installed
anywhere until it lands — which is the wrong order for the first app to use a
contract nothing has ever exercised. Cut it as a pre-release instead: CI
publishes ghcr.io/try-hola/dozzle:0.1.0-beta.1 from this PR, it can be
installed by OCI ref on a disposable VM, and it graduates to 0.1.0 once the
container-logs@1 grant is proven end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh
@pofallon pofallon changed the title feat(dozzle): live log viewer — the first container-logs@1 provider feat(dozzle): live log viewer — the first container-logs@1 provider (0.1.0-beta.1) Sep 20, 2026
@pofallon

Copy link
Copy Markdown
Contributor Author

VM test results

Installed on a disposable VM (Hola 0.11.0-rc.6, Authentik mode) alongside an uptime-kuma neighbour. The package works, and it found three platform bugs — fixed in try-hola/hola#468, which this now depends on.

What the package proved

install without --grant refused: "'dozzle' requires consent for privileged access it declares: container-logs@1", nothing deployed
install with --grant container-logs@1 completed, converged to running
hola-docker-proxy sidecar injected by the platform, on the server's own image
DOCKER_HOST inside dozzle tcp://hola-docker-proxy:2375
docker socket in dozzle absent
docker socket in the proxy present, read-only
a neighbour app's logs readable through the grant, real log lines
sh.hola.app / sh.hola.deployment labels present for grouping
neighbour's inspect no Env
front door 302 to Authentik

Consent turning out to be mandatory rather than optional is a better guarantee than I described in the PR body: there is no unprivileged half-installed provider state to fall into.

The three bugs (all shape, not policy)

  1. HEAD /_ping refused — Docker's client pings with HEAD first, so every standard client was rejected on its first call and reported it as "no engine".
  2. GET /info refused — a client on Docker's SDK exits without it; now rebuilt from an allowlist (drops HttpProxy/HttpsProxy, RegistryConfig, Plugins, Swarm…).
  3. Redacted inspect omitted HostConfig — dozzle segfaults on HostConfig.PortBindings; now present-but-empty, same nothing on the wire in a parseable shape.

After the fix: Connected to Docker, Accepting connections on :8080, healthy.

Envelope still holds

403 on: stats, archive?path=/etc/passwd, images, secrets, volumes, POST restart, POST exec, DELETE.

One thing to decide

/containers/{id}/stats stays 403, so the UI shows logs without CPU/memory graphs. Benign data and easy to add, but nothing needs it to function — I would rather widen the envelope on a real request than a nice-to-have.

Merge order

try-hola/hola#468 → a release carrying it → this. On an older server the package installs and the proxy appears, but dozzle will crash-loop.

🤖 Generated with Claude Code

pofallon added a commit to try-hola/hola that referenced this pull request Sep 20, 2026
The container-logs@1 proxy has shipped since 0.11.0-rc.1 with no provider to
exercise it. Packaging one (Dozzle, try-hola/apps#160) and installing it on a
VM found three ways the envelope refused the clients it exists for. All three
were shape, not policy — nothing here widens what the grant discloses.

1. HEAD was refused. Docker's own client pings with `HEAD /_ping` before
   anything else, so a GET-only allowlist rejected every standard client on its
   first call, and the client reported it as "Could not connect to any Docker
   Engine" — not as a refusal. HEAD is now allowed wherever GET is: it returns
   headers and no body, so it reveals strictly less than the GET it mirrors.

2. GET /info was refused outright. A client on Docker's SDK calls it to decide
   an engine is really there and exits without it. It is now rebuilt from an
   allowlist, the same way inspect is: what identifies and sizes the engine
   survives (name, version, OS, arch, cpu/memory, container counts) and
   everything describing how the host is configured does not — RegistryConfig,
   Labels, Plugins, DockerRootDir, SecurityOptions, Swarm, and above all
   HttpProxy/HttpsProxy, which routinely embed credentials.

3. The redacted inspect omitted HostConfig, Mounts and NetworkSettings
   entirely. A real daemon always returns them, so clients walk them without
   checking — Dozzle segfaults on HostConfig.PortBindings. Dropping the field
   denied the client, not the data. They are now present but empty: no host
   port map, no bind sources, no network topology, and the same nothing on the
   wire, in a shape an SDK client can parse.

Verified on a disposable VM against the real app: before, dozzle crash-looped
on "Could not connect"; after, it reports "Connected to Docker", serves, and
reads a neighbouring app's logs with sh.hola.app labels intact. The envelope
still holds — stats, archive, images, secrets, volumes, POST restart, POST
exec and DELETE all answer 403, and the neighbour's inspect carries no Env.


Claude-Session: https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh

Co-authored-by: Paul O'Fallon <paulofallon@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
pofallon added a commit to try-hola/hola that referenced this pull request Sep 20, 2026
Carries #468: the container-logs@1 grant can now serve a real Docker client.

The contract has shipped since 0.11.0-rc.1 with no provider to exercise it.
Packaging one (Dozzle, try-hola/apps#160) found three ways the proxy refused
the clients it exists for — HEAD was rejected outright, so every Docker client
failed on its first call; `/info` was refused, which an SDK client treats as
"no engine"; and the redacted inspect omitted structural fields clients walk
without nil-checking, segfaulting them. All three were shape, not policy: the
grant discloses exactly what it did before, and stats, archive, images,
secrets, volumes, restart, exec and delete were all re-verified at 403 on a VM
after the fix.

This is the release a container-logs provider needs. On rc.6 or earlier such
an app installs and its proxy sidecar appears, but the app itself crash-loops.


Claude-Session: https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh

Co-authored-by: Paul O'Fallon <paulofallon@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A pre-release version is never merged into main's package.json — the catalog
builder reads src/*/ on main for the stable entry and discovers pre-releases
from GHCR tags published off a PR. Dozzle has no stable predecessor, so it
enters the catalog as 0.1.0 or not at all.

Requires hola 0.11.0-rc.7 or newer: the container-logs@1 grant only serves a
usable Docker client from #468 onward.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh
@pofallon pofallon changed the title feat(dozzle): live log viewer — the first container-logs@1 provider (0.1.0-beta.1) feat(dozzle): live log viewer — the first container-logs@1 provider (0.1.0) Sep 20, 2026
@pofallon
pofallon merged commit 95c54d5 into main Sep 20, 2026
5 checks passed
@pofallon
pofallon deleted the feat/dozzle branch September 20, 2026 02:11
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.

1 participant