Metadata-Version: 2.3
Name: edwh-restic-ntfy
Version: 0.1.0
Summary: ntfy notifications for edwh-restic-plugin
Keywords: restic,backup,ntfy,notifications,edwh
Author: Robin van der Noord, Remco Boerma
Author-email: Robin van der Noord <robin.vdn@educationwarehouse.nl>, Remco Boerma <remco.b@educationwarehouse.nl>
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Requires-Dist: edwh-restic-plugin>=1.0.0
Requires-Dist: requests
Requires-Dist: termcolor
Requires-Dist: pytest ; extra == 'dev'
Requires-Dist: coverage[toml]>=6.5 ; extra == 'dev'
Requires-Dist: ruff ; extra == 'dev'
Requires-Dist: ty ; extra == 'dev'
Requires-Dist: types-requests ; extra == 'dev'
Requires-Python: >=3.12
Project-URL: Documentation, https://github.com/educationwarehouse/edwh-restic-ntfy#readme
Project-URL: Issues, https://github.com/educationwarehouse/edwh-restic-ntfy/issues
Project-URL: Source, https://github.com/educationwarehouse/edwh-restic-ntfy
Provides-Extra: dev
Description-Content-Type: text/markdown

# edwh-restic-ntfy

[ntfy](https://ntfy.sh) notifications for
[edwh-restic-plugin](https://github.com/educationwarehouse/edwh-restic-plugin): a failed backup
reaches your phone instead of dying in a cron log.

Core emits an event per restic operation and ships no notifiers itself. This package is one — it
registers a channel called `ntfy`, and does nothing at all until `[restic.notify] channels` names it.

## Installation

```console
(uv) pip install edwh-restic-ntfy
```

Installed beside `edwh-restic-plugin`, it is discovered through its entry point; no import or module
list is needed.

## Quick start

`.env` (gitignored, secrets only):

```dotenv
NTFY_TOKEN=tk_your_token_here
```

`.toml` (or `default.toml`, the committed template):

```toml
[restic.notify]
channels = ["ntfy"]

[restic.notify.ntfy]
url = "https://ntfy.sh/acme-backups"
```

Then check it works without waiting for a real failure:

```console
edwh restic.notify-test
edwh restic.notify-test --all-events
```

A public ntfy topic needs no credentials, so `NTFY_TOKEN` is optional. **A url is what activates the
channel**: named in `channels` but without one, it is skipped with a note and your backups run on.

## Routing: one url, or one per anything

`url` above is shorthand. The general form is a table whose keys say *which events* go where:

```toml
[restic.notify.ntfy]
server = "https://ntfy.example.com"        # optional, default https://ntfy.sh

[restic.notify.ntfy.urls]
"*" = "acme-all"               # everything, unless a key below claims it
"backup" = "acme-backups"           # one operation  (also spelled "backup.*")
"warning" = "acme-noisy"             # one level      (info | warning | error)
"failed" = "acme-oncall"            # one phase      (also spelled "*.failed")
"check.failed" = "https://ntfy.sh/acme-damage"   # one combination
```

Five kinds of key, freely mixed:

| Key                              | Means                        | Matches                              |
|----------------------------------|------------------------------|--------------------------------------|
| `"*"`                            | everything                   | any event                            |
| `"backup"`, `"backup.*"`         | one operation                | `backup.started`, `backup.failed`, … |
| `"failed"`, `"*.failed"`         | one phase, across operations | `backup.failed`, `check.failed`, …   |
| `"error"`, `"warning"`, `"info"` | one level                    | whatever core gives that level       |
| `"check.failed"`                 | one exact event              | only that one                        |

Operations are `backup`, `restore`, `check`, `forget`, `wipe`; phases are `started`, `succeeded`,
`failed`, `slow`. A key that could never match anything — `"faild"`, `"nonsense.failed"` — is
reported and skipped when the channel is configured, so a typo tells you at once instead of quietly
never firing.

### Which url wins

**The most specific key wins, and exactly one url set is chosen per event:**

```
"check.failed"  (exact)      >  "failed"  (phase)  >  "error"  (level)
                             >  "backup"  (operation)          >  "*"
```

Overlapping keys never fan out, so a single failure cannot wake someone twice, and `"*"` behaves as
the fallback rather than as an extra copy of everything.

Phase and level deliberately outrank operation. Routing by severity is the whole reason to have a
second url, and a `"backup"` key that swallowed `backup.failed` away from your oncall topic would
defeat it. Between the two, phase beats level, because it is the narrower statement.

When you *do* want one event in two places, say so with a list:

```toml
[restic.notify.ntfy.urls]
"failed" = ["acme-oncall", "https://ntfy.sh/acme-audit"]
```

Both are attempted; one dead host does not cost the other its notification.

### Topics and urls

A value containing `://` is used as it is. Anything else is a bare topic, resolved against `server`
(default `https://ntfy.sh`, overridable with `NTFY_SERVER` in `.env`). Mixing is fine — that is how
one channel reaches both a self-hosted server and ntfy.sh.

## Credentials

| `.env` key                   | Purpose                                                     |
|------------------------------|-------------------------------------------------------------|
| `NTFY_TOKEN`                 | access token, sent as `Authorization: Bearer` to every host |
| `NTFY_TOKEN_<HOST>`          | overrides `NTFY_TOKEN` for one host                         |
| `NTFY_USER`, `NTFY_PASSWORD` | basic auth, used when no token applies                      |
| `NTFY_SERVER`                | default server for bare topics                              |

`<HOST>` is the hostname uppercased with everything non-alphanumeric replaced by `_`:
`ntfy.sh` → `NTFY_TOKEN_NTFY_SH`, `ntfy.example.com:8080` → `NTFY_TOKEN_NTFY_EXAMPLE_COM_8080`.

Core hands this plugin only the `NTFY_*` keys from `.env`, so restic's password and other channels'
tokens are never in scope.

## What a notification looks like

Title, then body:

```
backup.failed - acme-prod@db-01

backup failed with exit 2 after 4m12s

repository: s3:acme
host: db-01
project: acme-prod
target: files
failed scripts: backup_files.sh (exit 1)

Fatal: unable to open repository at s3:...
```

The phase picks the icon (⏳ started, ✅ succeeded, 🚨 failed, 🐌 slow) and the level picks the ntfy
priority: `info` → 3, `warning` → 4, `error` → 5, so only bad news pushes past a normal
notification.

Override priorities with the same keys and precedence as urls:

```toml
[restic.notify.ntfy]
priority = 4                       # one value for every event

[restic.notify.ntfy.priorities]    # or per route
"*" = 2
"backup.failed" = 5
```

## All options

Under `[restic.notify.ntfy]`:

| Key                       | Default           | Meaning                                                  |
|---------------------------|-------------------|----------------------------------------------------------|
| `url`, `topic`            | —                 | shorthand for the `"*"` route                            |
| `urls`, `topics`          | —                 | the route table (or a bare value, same as `url`)         |
| `server`                  | `https://ntfy.sh` | base for bare topics                                     |
| `priority` / `priorities` | per level         | 1–5, single value or route table                         |
| `logs`                    | `true`            | include restic's stdout/stderr on failures               |
| `timeout`                 | `4.0`             | seconds per request                                      |
| `max_bytes`               | `3800`            | message budget; longer bodies are cut with `[truncated]` |

`events` and `min_level` are read by **core**, not by this plugin, and still apply:

```toml
[restic.notify.ntfy]
url = "https://ntfy.sh/acme-backups"
min_level = "warning"      # core drops info events before routing ever sees them
```

You rarely need `events`: this plugin narrows core's subscription to what its own table can deliver,
so `restic.notify-test` reports an event with no matching route as filtered rather than as a silent
success. An event that reaches us with no route sends nothing and says so once per event name.

### A worked setup

```toml
[restic.notify]
channels = ["ntfy"]
project = "acme-prod-db01"

[restic.notify.ntfy]
server = "https://ntfy.example.com"
logs = true

[restic.notify.ntfy.urls]
"*" = "backups-log"      # the timeline: started, succeeded, forget, …
"failed" = "backups-oncall"   # anything broken, whichever operation
"check.failed" = "backups-urgent"   # repository damage gets its own topic
"backup.slow" = "backups-log"      # a slow backup is not worth a phone call

[restic.notify.ntfy.priorities]
"check.failed" = 5
"*" = 2                  # the timeline should not buzz
```

## Security note

A notifier runs in-process and is trusted like any other dependency. Events carry an allowlist of
fields and never the repository URI — but `logs` on a failure event is restic's full stdout/stderr,
which can include repository paths and hostnames. Point failure routes at a topic you would be
comfortable pasting a terminal session into, protect it with a token, or set `logs = false` for that
channel.

## Development

```console
uv pip install -e .[dev]
edwh test.run
edwh plugin.release # to publish a new version with vommit
```


## License

`edwh-restic-ntfy` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
