Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ at `<app>.<HOLA_BASE_DOMAIN>` — **no host ports**, ingress is Traefik-only).
| 🖥️ Apache Guacamole | Clientless remote desktop gateway (RDP/VNC/SSH in the browser) |
| 💾 Backrest | Backup orchestrator (restic) with a web UI |
| 📚 Calibre-Web | Browse, read, and download your Calibre ebook library |
| 📜 Dozzle | Live log viewer for every app on the host |
| 🍵 Gitea | Self-hosted Git service |
| 🛩️ Hangar | Self-hosted fleet control plane — git repo hygiene & remediation across providers |
| 🏠 Homepage | A highly customizable application dashboard / start page |
Expand Down Expand Up @@ -128,9 +129,33 @@ every manifest in the catalog.
Contract ids are a **closed set** defined by the server
([`packages/shared/src/contracts.ts`](https://github.com/try-hola/hola/blob/main/packages/shared/src/contracts.ts)),
mirrored as an enum in `schemas/manifest.schema.json` — a contract has to exist
there before it can be declared here. Today: `backup@1` (app-provided),
plus `auth@1` and `push@1`, which the **platform** provides and no app may claim in
`provides`.
there before it can be declared here. Today: `backup@1` and
`container-logs@1` (app-provided), plus `auth@1` and `push@1`, which the
**platform** provides and no app may claim in `provides`.

Two properties of a contract decide what a manifest has to say about it.

**Participation — `declared` or `implicit`.** Most contracts are `declared`: being a
subject means doing something (running a pre-hook, exposing an identity), so the app
opts in via `accepts`. `container-logs@1` is `implicit`: a log collector reads from
*underneath* every app through the Docker API, so every install is already a subject
by virtue of running and there is nothing to opt into. An `accepts` naming it is a CI
error — Hola would drop it with a warning anyway.

**Shape — `brokered` or `provisioned`.** A `brokered` contract (`backup@1`) is an
*operation*: the provider asks Hola to do something and Hola acts on the acceptors,
so the provider's bundle has real work to do — calling the broker endpoints. A
`provisioned` contract (`container-logs@1`, `auth@1`) is a *connection*: Hola wires
up a scoped channel and steps out, and the provider needs no knowledge of Hola at
all. That is why the Dozzle package is almost entirely comment — the integration is
one line of manifest, and everything else is injected.

| Contract | Provider | Participation | Shape | Provider grant |
| --- | --- | --- | --- | --- |
| `auth@1` | platform | declared | provisioned | — |
| `backup@1` | app | declared | brokered | read-only mount of every app's data |
| `push@1` | platform | declared | brokered | — |
| `container-logs@1` | app | **implicit** | provisioned | a redacting, read-only Docker API façade |

CI (`bin/validate-manifest.mjs`) enforces the parts a schema can't:

Expand All @@ -139,6 +164,7 @@ CI (`bin/validate-manifest.mjs`) enforces the parts a schema can't:
| **error** | A `backup` block with no `backup@1` in `accepts[]` — the app filled in *how* and never said *whether*. |
| **error** | `accepts` naming `auth@1`/`push@1` without the matching block, which declares participation the app can't deliver. |
| **error** | `provides` naming a contract the platform provides, or either field naming a contract that doesn't exist. |
| **error** | `accepts` naming an `implicit` contract (`container-logs@1`) — there is no acceptor side to opt into. |
| **warn** | The app runs a database server and accepts nothing — it will show up as *uncovered*. |
| **warn** | The app runs a database server, accepts `backup@1`, and declares no hooks — the snapshot will copy live database files. |

Expand Down
14 changes: 13 additions & 1 deletion bin/validate-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,12 @@ const CONTRACTS = {
'backup@1': { block: 'backup', blockRequired: false, appProvided: true, impliedByBlock: false },
// Same as auth: the declared targets are the participation.
'push@1': { block: 'push', blockRequired: true, appProvided: false, impliedByBlock: true },
// The one contract with no acceptor side at all (ADR 0004 §11): a log
// collector reads from underneath every app via the platform-injected Docker
// façade, so there is nothing for a subject to opt into or implement. The
// server drops an `accepts` naming it with a warning; here it is an error, so
// a manifest saying something meaningless fails CI rather than deploying.
'container-logs@1': { block: null, blockRequired: false, appProvided: true, impliedByBlock: false, acceptable: false },
};

/** Manifest fields that take a bare string or an array of them. */
Expand Down Expand Up @@ -231,6 +237,12 @@ function checkContracts(app, manifest, manifestPath, issues, warnings) {
);
continue;
}
if (def.acceptable === false) {
issues.push(
`${app}/accepts: "${ref}" has no acceptor side — every install is already a subject by virtue of running, so Hola drops this declaration. Remove it.`
);
continue;
}
if (def.blockRequired && manifest?.[def.block] === undefined) {
issues.push(
`${app}/accepts: "${ref}" requires a "${def.block}" block — accepting it without one declares participation the app can't deliver`
Expand Down Expand Up @@ -259,7 +271,7 @@ function checkContracts(app, manifest, manifestPath, issues, warnings) {
// app into a contract on its author's behalf is the opposite of what the
// declaration is for.
for (const [ref, def] of Object.entries(CONTRACTS)) {
if (def.impliedByBlock) continue;
if (def.impliedByBlock || !def.block) continue;
if (manifest?.[def.block] !== undefined && !accepts.includes(ref)) {
issues.push(
`${app}/accepts: a "${def.block}" block is declared but "${ref}" is missing from accepts[] — the block says HOW the app participates, accepts[] says WHETHER it does`
Expand Down
1 change: 1 addition & 0 deletions icons/dozzle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 8 additions & 3 deletions schemas/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,8 @@
"accepts": {
"description": "Capability contract(s) this app OPTS IN to being a subject of (ADR 0004) -- 'backup@1' means \"back me up\". Acceptance is DECLARED, never inferred from the typed block: the block ('backup') says HOW an app participates, 'accepts' says WHETHER it does, and they are different facts. An app that needs no hooks at all (SQLite, flat-file) must still declare `accepts` -- without it Hola cannot tell a genuinely-covered app from one nobody ever considered, and the coverage view reads it as uncovered rather than fine.",
"anyOf": [
{ "$ref": "#/$defs/contractRef" },
{ "type": "array", "items": { "$ref": "#/$defs/contractRef" } }
{ "$ref": "#/$defs/acceptableContractRef" },
{ "type": "array", "items": { "$ref": "#/$defs/acceptableContractRef" } }
]
},
"upgrade": {
Expand Down Expand Up @@ -314,12 +314,17 @@
"contractRef": {
"type": "string",
"description": "A capability contract reference, `<id>@<version>`. CLOSED SET: a contract is a promise about *server* behavior, so matching two strings does nothing unless Hola implements the middle. Mirrors CONTRACTS in try-hola/hola packages/shared/src/contracts.ts -- adding a contract there is what makes it declarable here. Enumerated rather than left open because the server drops an unrecognized ref with a warning (ADR 0003 forward-compat), which would make a typo'd `backups@1` a silent no-op instead of a CI failure.",
"enum": ["auth@1", "backup@1", "container-logs@1", "push@1"]
},
"acceptableContractRef": {
"type": "string",
"description": "The subset of #/$defs/contractRef an app can OPT IN to, i.e. those whose participation is `declared`. An `implicit` contract (container-logs@1) has no acceptor side: every install is a subject by virtue of running, and the server drops an `accepts` naming one with a warning, so declaring it here is a manifest error rather than a silent no-op.",
"enum": ["auth@1", "backup@1", "push@1"]
},
"appProvidedContractRef": {
"type": "string",
"description": "The subset of #/$defs/contractRef whose provider is a catalog app rather than the platform, and which may therefore appear in `provides`.",
"enum": ["backup@1"]
"enum": ["backup@1", "container-logs@1"]
},
"backupHook": {
"type": "object",
Expand Down
91 changes: 91 additions & 0 deletions src/dozzle/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# dozzle

[Dozzle](https://dozzle.dev) — a live log viewer for every app running on the host.
Open it and you get a searchable, streaming tail of any container's output, grouped
by the app it belongs to.

Reachable at `https://dozzle.<HOLA_BASE_DOMAIN>` once installed.

- **One container** on port **8080**, plus the `hola-docker-proxy` sidecar Hola
injects for it (see below) — nothing to configure.
- **No host ports** — Hola routes ingress through Traefik to container port 8080.
- **One small data dir**: `/data` holds user display preferences. Dozzle stores no
log data of its own; it streams from the Docker API and keeps nothing.

## Container logs

This is the first package to use the **`container-logs@1`** capability contract, and
it exists as much to exercise that mechanism as to view logs.

A log viewer normally needs the Docker socket:

```yaml
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro # NOT what this package does
```

That mount hands one container the ability to read every other container's
environment variables, mounts and networking, and to start, stop or exec into
anything on the host — including Hola's own server. `:ro` doesn't help: it restricts
the filesystem node, not the API spoken over it. Hola's compose validator rejects the
mount outright, so no catalog app can do this.

Instead the manifest declares:

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

At install the operator is asked to consent to that grant. When they do, Hola injects
a `hola-docker-proxy` sidecar — running **Hola's own image**, not Dozzle's — which
holds the socket read-only and exposes a deliberately narrow slice of the Docker API:

| Allowed (GET only) | What it's for |
| --- | --- |
| `/containers/json` | the container list, with each app's `sh.hola.*` labels |
| `/containers/{id}/logs` | the logs themselves |
| `/events` | live start/stop, so the list stays current |
| `/containers/{id}/json` | inspect, **rebuilt** without `Config.Env`, `Cmd`, `Entrypoint`, `HostConfig`, `Mounts` or `NetworkSettings` |
| `/_ping`, `/version` | connection probes |

Everything else — every write, every other path — answers `403 not permitted by the
container-logs grant`. Hola then points `DOCKER_HOST` at that proxy on every service
in this deployment, unconditionally.

The result: **Dozzle needs no knowledge of Hola at all.** It reads `DOCKER_HOST` the
way it would anywhere else; what changed is only what's on the other end of it. That
is the point of a *provisioned* contract — the platform wires up a scoped connection
and steps out, rather than asking the app to implement anything.

### What you don't get, and why

Two Dozzle features need Docker API access the grant deliberately withholds, so both
are disabled in this package rather than left to fail in the UI:

| Feature | Needs | Status |
| --- | --- | --- |
| Container actions (start/stop/restart) | `POST /containers/{id}/…` | off — `DOZZLE_ENABLE_ACTIONS=false` |
| Shell into a container | `POST /exec` | off — `DOZZLE_ENABLE_SHELL=false` |

Per-container CPU and memory stats (`GET /containers/{id}/stats`) and host info
(`GET /info`) are also outside the grant today. Dozzle degrades rather than breaking:
you get logs, not resource graphs.

### Grouping by app

Hola labels every app container it runs with `sh.hola.app`, `sh.hola.deployment` and
`sh.hola.name`, and those come through the container list. Use the **Container filter**
setting to scope the view, e.g. `label=sh.hola.app=calibre-web` to watch one app.

## Who can see it

`auth.mode: forward-auth` — Authentik gates the door, so only users your Hola instance
authorizes reach Dozzle at all. Dozzle is then configured with its `forward-proxy`
auth provider reading Authentik's identity headers, so the person who signed in is the
person Dozzle shows, rather than every visitor sharing one anonymous session.

**Worth being clear about the blast radius:** anyone who can open Dozzle can read the
logs of *every* app on the host, and logs routinely contain more than their authors
intended — tokens in URLs, email addresses, stack traces with data in them. The
`container-logs@1` grant is disclosed at install for exactly this reason. Treat access
to this app as equivalent to read access across the whole instance.
13 changes: 13 additions & 0 deletions src/dozzle/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "dozzle",
"version": "0.1.0",
"description": "Dozzle — live log viewer for every app on the host (Hola app package)",
"license": "MIT",
"oci": {
"annotations": {
"org.opencontainers.image.title": "dozzle",
"org.opencontainers.image.description": "Live log viewer for every app on the host",
"org.opencontainers.image.version": "${npm_package_version}"
}
}
}
62 changes: 62 additions & 0 deletions src/dozzle/src/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Dozzle — a live log viewer for every app on the host. Packaged for Hola:
# Traefik-only ingress (no host ports), all persistence bind-mounted under
# ${HOLA_APP_DATA}.
#
# The interesting thing about this file is what ISN'T in it. Dozzle reads the
# Docker API, and normally that means `- /var/run/docker.sock:/var/run/docker.sock`
# — handing one container the ability to read every other container's
# environment, mounts and networking, and to start, stop or exec into anything
# on the host. Hola's compose validator rejects that mount outright, and this
# package does not want it.
#
# Instead the manifest declares `provides: ["container-logs@1"]`. When the
# operator consents to that grant at install, the server injects a
# `hola-docker-proxy` sidecar of its own — running Hola's image, not this one —
# which holds the socket read-only and answers a deliberately narrow slice of
# the Docker API: GET only, and only the container list, container logs, the
# event stream, a redacted inspect (no env, no mounts, no networking) and the
# ping/version probes. Everything else answers 403. The server then points
# DOCKER_HOST at that proxy on every service here, unconditionally — the wiring
# belongs to the grant, not to the app.
#
# So Dozzle talks to DOCKER_HOST exactly as it would anywhere else and needs no
# knowledge of Hola whatsoever; what changes is only what is on the other end of
# it. See `Container logs` in README.md.
services:
dozzle:
image: ghcr.io/amir20/dozzle:v11.1.0@sha256:7c4fb7f8124f5dea15ade881535cc19cc9a03e66639fd28154ff43a93b78944b
environment:
# Identity, from Hola's Authentik gate. Dozzle's `forward-proxy` provider
# trusts identity headers set by the proxy in front of it, which is exactly
# what forward-auth gives us — so the person Authentik authenticated is the
# person Dozzle shows, rather than every visitor sharing one anonymous view.
# The header names are Authentik's; Dozzle's defaults are Authelia's.
DOZZLE_AUTH_PROVIDER: forward-proxy
DOZZLE_AUTH_HEADER_USER: X-authentik-username
DOZZLE_AUTH_HEADER_EMAIL: X-authentik-email
DOZZLE_AUTH_HEADER_NAME: X-authentik-name
# Off by phoning-home policy: a self-hosted log viewer has no business
# reporting usage to anyone.
DOZZLE_NO_ANALYTICS: "true"
# Both of these need Docker API writes (POST /containers/{id}/{start,stop,
# restart}, POST /exec). The container-logs grant is read-only, so they
# would 403 on use — disabled explicitly rather than left to fail in the UI.
DOZZLE_ENABLE_ACTIONS: "false"
DOZZLE_ENABLE_SHELL: "false"
DOZZLE_FILTER: ${DOZZLE_FILTER}
DOZZLE_LEVEL: ${DOZZLE_LEVEL}
volumes:
# User settings (pinned containers, display preferences). Dozzle stores no
# log data of its own — it streams from the Docker API and keeps nothing,
# which is why this package is a viewer rather than a log store.
- ${HOLA_APP_DATA}/data:/data
# Web UI. Hola routes ingress through Traefik to this container port
# (manifest.ingress.port) — no host ports.
expose:
- "8080"
healthcheck:
test: ["CMD", "/dozzle", "healthcheck"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
77 changes: 77 additions & 0 deletions src/dozzle/src/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
{
"name": "dozzle",
"version": "0.1.0",
"title": "Dozzle",
"description": "Live log viewer for every app on the host",
"icon": "https://raw.githubusercontent.com/try-hola/apps/main/icons/dozzle.svg",
"category": "tools",
"tags": [
"logs",
"monitoring",
"observability",
"containers"
],
"provides": [
"container-logs@1"
],
"accepts": [
"backup@1"
],
"ingress": {
"service": "dozzle",
"port": 8080
},
"defaultEnv": [
{
"key": "DOZZLE_FILTER",
"value": "",
"isSecret": false,
"label": "Container filter",
"type": "string",
"advanced": true,
"description": "Limit which containers are shown, e.g. label=sh.hola.app=calibre-web to watch one app. Empty shows every container Hola's log grant exposes."
},
{
"key": "DOZZLE_LEVEL",
"value": "info",
"isSecret": false,
"label": "Dozzle's own log level",
"type": "enum",
"options": [
{
"value": "error"
},
{
"value": "warn"
},
{
"value": "info"
},
{
"value": "debug"
},
{
"value": "trace"
}
],
"advanced": true,
"description": "Verbosity of Dozzle's own logs. Raise to debug when diagnosing what it can and cannot read through the log grant."
}
],
"defaults": {
"ports": [
{
"container": 8080,
"protocol": "tcp"
}
],
"volumes": [
{
"containerPath": "/data"
}
]
},
"auth": {
"mode": "forward-auth"
}
}
Loading