Notifications - sparkycz1/honeypot-shelf GitHub Wiki
Your honeypot lies to attackers all day — the least it can do is tell you the truth by email.
Nav bar → Notifications, open to every logged-in user regardless of
access level — anyone creates their own named NotificationRules, each
scoped to any number of companies they can already see, or any number
of honeypots — never a mix of both in the same rule. Deliberately much simpler than debcontrol's own Notifications
(admin-authored rules, role-targeted recipients, condition thresholds)
— matching this app's flatter RBAC and an explicit decision to keep
rule creation self-service, open to every access level.
The create/edit form asks the same four questions in order, then shows the actual wording last:
- What — any number of companies (each one covers every honeypot in it, including ones added later — re-resolved on every sweep, no rule edit needed) or any number of honeypots — pick the kind first, then check as many as apply. A superadmin can pick any company/honeypot; anyone else only ones they already have access to — checked server-side, not just by what the picker offers.
-
Why — which of the three events fire it, each independently:
- New alert — every newly ingested OpenCanary event.
- Becomes unavailable — continuously unreachable for at least this rule's own threshold (minutes).
- Becomes available again — continuously reachable again for at least this rule's own threshold, and only after an "unavailable" notification actually fired for it — a blip that recovers before its own threshold never claims "it's back" for something that was never reported down.
- How — email (default), a webhook POST, a push app (ntfy, Gotify, Telegram, Discord, Pushover) or a team chat (Mattermost, Slack, Microsoft Teams) — see Push and chat channels.
- Where — for email, defaults to your own account email; override it per rule (e.g. a shared team alias). For every other channel, its URL, token and/or recipient — any URL must resolve to a public address (see the SSRF note below).
- The actual wording, collapsed at the end — one subject/body pair per event kind, prefilled with the built-in default text in your own current UI language, editable per rule. Leave a field blank to keep using that default — including any future translation, if you later switch your own UI language. There's no shared, admin-edited template any more (see "What changed" below).
flowchart LR
Event["New OpenCanary event<br/>on Honeypot X"] --> Match{"Which rules<br/>match X?"}
Match -->|"scope = honeypot X"| R1["Rule A (user 1)"]
Match -->|"scope = X's company"| R2["Rule B (user 2)"]
Match -->|"scope = a different company"| Skip["— not matched —"]
R1 --> Send1["Render wording in user 1's language<br/>→ email or webhook"]
R2 --> Send2["Render wording in user 2's language<br/>→ email or webhook"]
Notifications used to be one row per (user, honeypot) with a fixed instance-wide template per event, superadmin-edited in Settings. Two things this redesign replaced, both by explicit request:
-
Scope widened from "one honeypot" to "a honeypot, or a whole
company" —
NotificationRule(wasHoneypotNotificationSubscription), a named row instead of an anonymous toggle, letting one person hold several rules for different purposes. - Wording moved from one shared, superadmin-only template to a per-rule override — Settings no longer has a "Notifications" tab at all; every rule's own wording lives with the rule.
No data-migration path for the old subscriptions — a young, self-service-only feature with a small blast radius; recreate them as rules.
Because a company-scoped rule can cover many honeypots that each go
up/down independently, "have we already notified for this outage" can't
live on the rule itself the way a honeypot-scoped one could — it's a
separate NotificationRuleState row per (rule, honeypot) pair, created
lazily the first time a sweep evaluates that pairing, and reset once the
matching notification fires. Both Honeypot.unreachable_since and its
mirror reachable_since track when the current streak started —
distinct from last_ping_at, which is overwritten every sweep tick
regardless of outcome. Both event kinds hook directly into the existing
reachability and log-poll sweeps — no new Celery Beat schedule entry.
A port scan raises dozens of OpenCanary events within seconds, and a rule
sends one notification per event. The rule's At most one alert
notification every (minutes) box (NotificationRule.alert_throttle_minutes,
empty = no limit, up to a week) caps that:
- Each honeypot the rule covers sends at most one alert notification per window. Another honeypot has its own window.
- Alerts inside the window are not sent. They are counted per rule and
honeypot (
NotificationRuleState.alerts_held_back), not written to the delivery history one row each. - The first alert after the window ends with a line saying how many were
held back and since when. Webhooks get the number as
held_back. - A notification muted by a maintenance window, or one with nowhere to go (no target, SMTP off), does not start a window.
- Only alerts are throttled. "Unavailable" and "recovered" have their own debounce above. Every event is still stored and shown on the Activity tab; throttling only limits the messages.
Only these alert types. Under the alert switch a rule lists the alert
types OpenCanary reports (opencanary_logtypes.alert_logtypes()). With
none ticked (NotificationRule.alert_event_types NULL) the rule sends
every alert; otherwise wants_alert() drops the events of other types
before throttling, maintenance windows and acknowledgements are applied.
Three health events about the honeypot itself, each its own switch:
| Event | Sent when |
|---|---|
| A disk will be full within a week | the disk forecast expects a mounted filesystem to fill within 7 days (HEALTH_DISK_FULL_DAYS) |
| A system service failed | a systemd unit is in the failed state |
| A reboot is needed after an update | the facts sweep sees a newer kernel installed than running |
Each is sent once, when it appears — Honeypot.health_announced
remembers which mount points / units were already announced, so a
problem is announced again only after it went away and came back, and
switching the event on while a problem is ongoing sends nothing for it.
The message names what is concerned ({details}). An acknowledged
honeypot sends none of them. Rules are still managed in the web UI only.
Each is a single HTTPS request built by
app/services/push_channels.py
(ported from debcontrol) — no extra notification library in the process
that holds every credential. What each channel needs on the rule:
| Send via | URL | Token | Recipient |
|---|---|---|---|
| ntfy | topic URL, e.g. https://ntfy.sh/my-honeypots
|
access token (optional, protected topics) | — |
| Gotify | server URL, e.g. https://gotify.lan
|
application token | — |
| Telegram | — | bot token (@BotFather) | chat id |
| Discord | channel webhook URL | — | — |
| Pushover | — | application API token | user/group key |
| Mattermost | incoming webhook URL | — | — |
| Slack | incoming webhook URL | — | — |
| Microsoft Teams | Workflows "Post to a channel when a webhook request is received" URL | — | — |
- Mattermost and Slack get
{"text": ...}with the subject bolded; Teams gets an Adaptive Card (the format Workflows webhooks expect). Each message is clipped to the service's own size limit. - The token is stored only encrypted (
channel_token_encrypted, the sameencrypt_secretas every other secret — re-encrypted automatically on a full restore onto an instance with a different key) and is never rendered back: the edit form leaves it blank, and saving with the field still blank keeps the stored one. Switching the rule to a channel without a token clears it. - Every URL (ntfy, Gotify, Discord and the three chats as well as the plain webhook) goes through the same SSRF guard as a webhook — when the rule is saved and again right before each send — and requests never follow redirects. Telegram and Pushover post to their fixed public APIs.
- The delivery history shows a URL as
https://host/…, Telegram as "Telegram chat <id>" and Pushover as "Pushover <key>" — never a token or a secret URL path; a token that appears in an error message is masked too. - Notification rules stay web-only (there's no REST API for them — they are each user's own self-service settings, like the rest of this page).
Scheduling → Maintenance windows (/scheduling/maintenance, ported
from debcontrol) plans work on some honeypots. A window belongs to one
company, like a scheduled task, and covers either every honeypot in it
(including ones added later) or the honeypots listed; write access to
that company is needed to see or change it, on the web and in the API.
While a window is active:
- "Unavailable" and "available again" notifications about a covered honeypot are muted, for every user's rules.
- New-alert notifications are muted only when the window has Also mute new-alert notifications set — off by default, since an alert is a security signal; turn it on when your own work (scans, test logins) would set alerts off.
- With Also pause scheduled tasks, scheduled tasks skip covered honeypots for that run (not queued for later); the run's summary says how many were paused.
A muted notification is not lost silently: the owner's notification
history shows it as Muted (window name) (NotificationLog.muted_by). A
"Send test" is never muted. The honeypot's Overview shows In maintenance
until … while a window covers it. An active window can be ended early
and an upcoming one cancelled (End now); both stay listed as ended.
Windows last at most 31 days, and times are entered in the instance's
TZ.
REST API: GET/POST /api/v1/maintenance-windows, GET/PUT/DELETE /api/v1/maintenance-windows/{id} and POST …/{id}/end, same fields as
the form (owner_company_id, name, reason, starts_at, ends_at,
all_honeypots, honeypot_ids, pause_scheduled_tasks,
mute_alerts); each window in a response also carries its state
(active, upcoming, ended). Audit actions:
maintenance_window.create/update/end/delete.
"I know about this one." On a honeypot's Overview, Acknowledge a problem (write access to one of its companies) takes a duration — until it recovers, or 1 hour to 7 days — and an optional note.
- While it is active, alert and "unavailable" notifications about that honeypot are withheld; the history lists them as Acknowledged (user). The "recovered" notification is always sent.
- Events are still ingested, stored and shown on the Activity tab and the map. Only the messages are withheld.
- It ends when the honeypot is reachable again after being unreachable, when its time is up, or when someone clears it. For alerts on a honeypot that stays reachable, give it a duration or clear it by hand.
- The page shows who acknowledged it, when, until when and the note; the honeypot list shows an "acknowledged" badge.
- Unlike a maintenance window it does not pause scheduled tasks, and it covers exactly one honeypot.
- Audited as
honeypot.acknowledge/honeypot.acknowledge.clear. An acknowledgement that ends on its own is not audited.
REST API: POST /api/v1/honeypots/{id}/acknowledge with
{"hours": 8, "note": "..."} (omit hours for "until it recovers"),
DELETE on the same path to clear. A honeypot carries an
acknowledgement object (or null).
A webhook, push or chat rule fires even when SMTP is off; an email rule is silently
skipped if SMTP isn't configured. Every send attempt — real or test —
is logged (NotificationLog, purged on its own configurable retention,
Settings → Checks & retention), and each user sees only their own last
200 attempts at Notifications → Notification history (a webhook's
target is shown — and logged on the server — as https://host/…: its
path is usually the secret, so only the rule itself keeps the full URL)
— that page
live-refreshes over WebSocket the moment a new attempt is logged for
that user (see Honeypot Management's "Live updates over
WebSocket"). "Send
test" fires one synthetic alert straight to a rule's current
channel/target, against a real honeypot in its scope, bypassing
rule-matching and debounce state entirely.
Because a webhook URL here is entered by any logged-in user, not just
a superadmin authoring a rule (debcontrol's own trust boundary), it's a
real SSRF vector without a guard — a low-privileged account could
otherwise point it at a cloud metadata endpoint or another container on
the compose network and use worker as a network probe. The URL is
resolved and checked against every non-public address class (loopback,
link-local, private, reserved, multicast, unspecified) both when a rule
is saved and again immediately before every send — defending against
the resolved address changing between the two, a classic DNS-rebind.