diff --git a/README.md b/README.md index bf88bf9..631acca 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ at `.` β€” **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 | @@ -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: @@ -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. | diff --git a/bin/validate-manifest.mjs b/bin/validate-manifest.mjs index 97933a1..b898bc9 100755 --- a/bin/validate-manifest.mjs +++ b/bin/validate-manifest.mjs @@ -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. */ @@ -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` @@ -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` diff --git a/icons/dozzle.svg b/icons/dozzle.svg new file mode 100644 index 0000000..34d5f97 --- /dev/null +++ b/icons/dozzle.svg @@ -0,0 +1 @@ + diff --git a/schemas/manifest.schema.json b/schemas/manifest.schema.json index 735659b..7faf386 100644 --- a/schemas/manifest.schema.json +++ b/schemas/manifest.schema.json @@ -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": { @@ -314,12 +314,17 @@ "contractRef": { "type": "string", "description": "A capability contract reference, `@`. 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", diff --git a/src/dozzle/README.md b/src/dozzle/README.md new file mode 100644 index 0000000..968dfbc --- /dev/null +++ b/src/dozzle/README.md @@ -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.` 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. diff --git a/src/dozzle/package.json b/src/dozzle/package.json new file mode 100644 index 0000000..07783af --- /dev/null +++ b/src/dozzle/package.json @@ -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}" + } + } +} diff --git a/src/dozzle/src/compose.yaml b/src/dozzle/src/compose.yaml new file mode 100644 index 0000000..39f0d65 --- /dev/null +++ b/src/dozzle/src/compose.yaml @@ -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 diff --git a/src/dozzle/src/manifest.json b/src/dozzle/src/manifest.json new file mode 100644 index 0000000..bf78ec8 --- /dev/null +++ b/src/dozzle/src/manifest.json @@ -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" + } +}