Skip to content

Notifications

Jakub Adamek edited this page Oct 5, 2026 · 9 revisions

🔔 Notifications

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.

Building a rule: what, why, how, where

The create/edit form asks the same four questions in order, then shows the actual wording last:

  1. 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.
  2. 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.
  3. 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.
  4. 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).
  5. 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"]
Loading

What changed from the first version

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 (was HoneypotNotificationSubscription), 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.

The debounce, mechanically

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.

Alert throttling

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.

Alert types and honeypot health (0.69.0)

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.

Push and chat channels

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 same encrypt_secret as 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).

Maintenance windows

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.

Acknowledging a problem

"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).

Delivery, history, and "Send test"

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.

The SSRF guard on webhooks

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.

Clone this wiki locally