Honeypot Management - sparkycz1/honeypot-shelf GitHub Wiki

🍯 Honeypot Management

SSH in, ask nicely, never trust a first handshake.

Everything here is ported from debcontrol's own machine-management layer close to unchanged β€” a Honeypot is managed exactly like a debcontrol Machine (facts, packages, monitoring, updates, terminal, logs, scheduling). This page covers what's genuinely different: how a honeypot gets provisioned, reached, and how its own OpenCanary events get in.

πŸ”‘ When a honeypot's SSH host key changes

Every connection checks the server's key against the pinned fingerprint and is refused on a mismatch β€” that never changes. What a mismatch used to look like, though, was just "OpenCanary silent" with no reason given. A legitimate change is common enough: a reinstall, or a cloud-init VM moved to another cluster, where a new instance id makes cloud-init regenerate the host keys (ssh_deletekeys: false in the user-data avoids that).

The OpenCanary log poll β€” the one connection every pinned honeypot gets regularly β€” records what the server presented instead (Honeypot.host_key_changed_fingerprint/host_key_changed_at):

  • the Overview shows a warning with the pinned and the presented fingerprint, the honeypot list and the status line an "SSH key changed" badge;
  • the honeypot's settings get an "Accept the new key" button (confirmation, audited as honeypot.host_key.trust with the previous fingerprint). Verify the fingerprint on the machine's own console first β€” ssh-keygen -lf /etc/ssh/ssh_host_*_key.pub β€” an unexplained change can mean something is intercepting the connection;
  • with no change noticed, the same section offers to look at the key the server presents right now and pin that instead.

Nothing is trusted automatically, and the record clears on its own when the pinned key answers again.

🌱 Provisioning a brand-new device

Initialize is the one write path that SSHes into a device with no Honeypot row at all β€” everything else in app/ssh/* requires one first. With no prior host-key fingerprint to check, trust here is deliberately trust-on-first-use β€” the one exception to the pinned-verification policy app.ssh.client enforces everywhere else.

Runs live in the web process, not Celery (a run can take up to an hour and the operator needs to watch it). Its second-to-last step installs this app's own shared identity key plus every superadmin's personal key(s) into the connected account β€” idempotently, additively β€” so both the app and every superadmin can reach the device directly afterward.

Superadmin personal SSH keys

User.ssh_public_keys (My account) lets a superadmin paste their own key(s) for logging into a honeypot directly β€” superadmin-only by design, since granting host-level SSH into the whole fleet is a superadmin-tier capability, not something company scoping should ever widen. Two things read it: Initialize (installs every current superadmin's key on a fresh device) and "Push to every honeypot" (does the same for the existing fleet). Both, and Settings' own SSH-identity push, share one command builder β€” strictly additive: every key is checked before being appended, so a key added by hand is never overwritten or duplicated.

πŸ”Œ VPN connectivity: NetBird or WireGuard

The problem: SSH-management-plane features need web/worker to open a plain TCP connection to a honeypot β€” impossible if it sits behind a NAT with no forwarded port, a common shape for a honeypot specifically. Two fixes, mutually exclusive, picked in Settings β†’ VPN: web/worker join a virtual network as a peer, the honeypot joins the same one (via Initialize), and a plain SSH connect to its tunnel address just works.

NetBird WireGuard
Solves NAT on both ends? Yes β€” NetBird's own relay/coordination server does the hole-punching No β€” needs a WireGuard server the operator already runs
What this app enters A setup key + management URL + an optional hostname (so this peer shows up in NetBird's dashboard as something recognizable, not the container's bare Docker hostname β€” only takes effect on that peer's first registration; renaming later needs removing it from the dashboard first, then reconnecting) A complete peer .conf, same as any other client gets
"Log" The NetBird daemon's own log, tailed Thinner β€” wg-quick's own output plus wg show

Both run inside an optional privileged vpn sidecar (docker-compose.vpn.yml); web/worker borrow its network namespace rather than gaining privilege of their own. AppSettings.vpn_provider is set only by whichever connect() last succeeded, never edited directly β€” connecting one best-effort disconnects the other. This is a different NetBird connection from the one on the Initialize form β€” that one joins the honeypot being provisioned; this one joins this app's own containers.

On every web startup (app.main._reconnect_vpn_if_configured), the app re-connects whichever provider was last active β€” but for NetBird only when netbird status reports it's not already connected, and even then via app.services.netbird.ensure_connected rather than resending the stored setup key directly: it first tries a plain netbird up (no key at all), which reconnects this peer from its own already-persisted registration (/etc/netbird, a named volume, usually survives a plain container restart or image upgrade on its own) β€” only falling back to connect() (actually spending the setup key) when NetBird itself reports this peer has no persisted registration at all (a genuinely fresh peer, or one removed from the NetBird dashboard). The setup key is single-use on NetBird's side, already consumed by the very first successful registration β€” an earlier version of this reconnect path (and Settings β†’ VPN's "Restart" button) resent it unconditionally on every restart, which failed outright once that first registration had gone through (a setup key is invalid retry loop in the NetBird log). Both call sites go through ensure_connected now, so neither can hit that again.

That fix alone still wasn't enough in practice, though: the deeper bug was that netbird's own default state file lives at /var/lib/netbird/default.json β€” not anywhere under /etc/netbird, the directory the netbird_state volume actually mounts. The command that starts the daemon (netbird service run) now passes --config /etc/netbird/config.json explicitly, forcing it to persist there instead. Without that flag, nothing was ever writing to the mounted volume at all β€” confirmed live on a box that had connected before: /etc/netbird was a genuinely empty directory. Every plain image rebuild (any upgrade, not just a docker compose down -v) threw away whatever /var/lib/netbird had accumulated, so the peer showed up unregistered on the very next restart regardless of the ensure_connected fix above β€” which then correctly fell back to the stored setup key, which was itself already spent, so that fallback failed too. Pasting in a new setup key worked once (a fresh registration succeeds and starts using the client normally) but hit the exact same unmounted-path problem on the next upgrade. Verified by hash-comparing /etc/netbird/config.json across a full docker compose rm + up cycle (simulating an image rebuild) β€” identical file, survives now.

πŸ”’ Honeypot Config: read-only root + the OpenCanary module editor

Two independent live-SSH sections, neither persisted in this app's own DB β€” the honeypot's own state is the only copy of the truth.

Read-only root filesystem β€” only offered for a honeypot with supports_readonly_root set (live-detected on every facts refresh via command -v raspi-config, see app.ssh.platform_detect's module docstring for why that β€” not the OS/distro itself β€” is the actual gate). Protects the SD card from write wear via Raspberry Pi OS's own overlay filesystem β€” takes effect on next reboot, not immediately (the Config tab says so explicitly). Not literal read-only: every write still succeeds at runtime, just discarded on reboot. Debian/Ubuntu never show this section at all, and get no substitute for it β€” those releases typically aren't running off an SD card, so the whole reason this toggle exists doesn't apply. OpenCanary's log just goes to a plain persistent path instead there (see Initialize's own "Where OpenCanary's own log lives" section).

The OpenCanary module editor is a category-by-category form over every OpenCanary module (FTP, HTTP(S), SSH, Telnet, databases, RDP, VNC, SIP, SNMP, NTP, TFTP, Git, LLMNR, a TCP banner listener, portscan, Samba). Saving re-reads the live config, merges the form in, writes it back, restarts opencanary. No module is ever force-enabled by Initialize or this editor's own defaults. Every successful save also tags the honeypot with its now-enabled modules β€” a manually-added tag survives every future save.

🎭 MAC address: looking like another manufacturer's device

The Config tab's MAC address section (and an optional field on Initialize) gives the honeypot a different MAC address, so on its network it reads as a printer, a camera or a switch rather than a Raspberry Pi.

  • Choosing one: type it in, or pick a manufacturer from a drop-down with a search box (it opens with the manufacturers that have the most prefixes and narrows as you type; combo.js) β€” the app takes one of that manufacturer's prefixes, makes up the rest at random, and "Draw another" draws again (app.services.mac_vendors).
  • The manufacturer list is not shipped. Settings β†’ Integrations β†’ "MAC address manufacturers" holds its address and how often to download it again (default 168 hours, looked at hourly); it is empty by default, and until it is set only typing works. Format: one prefix per line β€” six hex digits, a tab or spaces, the name (000C29<TAB>VMware, Inc.). Only a public http(s) address is fetched, 10 MB at most, and something that is not a list replaces nothing.
  • On the honeypot (app.ssh.mac_address) it is one systemd unit, honeypotshelf-mac.service, running ip link set … address … on the default-route interface before network-pre.target β€” ahead of NetworkManager, systemd-networkd, ifupdown and dhcpcd alike, which all keep the address an interface already has. Deliberately not a .link file: only the first matching one applies, so ours would replace the distribution's interface-naming policy.
  • It takes effect at the next reboot, never live. Changing it under the running connection would cut this app's own SSH session. With DHCP the honeypot then usually comes back on a different IP address β€” change it in the honeypot's settings afterwards.
  • Refused while root is read-only (see above): the unit would be gone at the very boot it is meant for.
  • "Return to the hardware address" removes the unit. The hardware address is never overwritten anywhere.

Needs write access to that honeypot; both actions are audited (honeypot.mac_address.set / .reset).

⬆️ Updates: strategies, held packages, changelogs, rolling runs

An update run (HoneypotUpdateRun, app.ssh.updates) is always apt-get update, the chosen upgrade step, autoremove/autoclean, then flatpak/snap if present. Since 0.70.0 the upgrade step is one of:

Strategy What runs When to pick it
full_upgrade (default) apt-get full-upgrade everything; may install new dependencies and remove conflicting packages
upgrade apt-get upgrade never removes or newly installs a package; such updates are held back
security apt-get install --only-upgrade of the packages apt's own simulation reports from a *-security suite only security fixes
dist_upgrade apt-get dist-upgrade the older name of full_upgrade; still accepted (stored runs, scheduled tasks, API), no longer offered in the forms

The preview simulates exactly the chosen step (-s).

  • A failed update check stays visible β€” the reason (the honeypot was unreachable, or apt-get update exited non-zero, with what apt itself printed) is kept in Honeypot.updates_check_error until the next check succeeds: a red "check failed" badge in the honeypot list, the full error on the Updates tab.
  • Held packages β€” "hold"/"release" on the Updates tab run apt-mark hold|unhold; the update check reads apt-mark showhold into Honeypot.apt_held_packages. Needs root or a sudoers entry for /usr/bin/apt-mark: Initialize and onboarding grant it, and the readiness check reports a honeypot set up before 0.70.0 that lacks it (re-running onboarding from its Overview adds it).
  • Changelog β€” apt-get changelog <package> read live over SSH and cut at the installed version; nothing is stored.
  • Package names are validated (is_safe_package_name) before they reach a command line, and quoted on top of that.
  • A scheduled "System update" has two more choices (app.tasks.jobs._finish_update_run):
    • Reboot afterwards: only if the update needs it β€” after a successful run the honeypot is checked (/run/reboot-required, or a newer kernel installed than running), rebooted, and waited for up to 15 minutes. The run records reboot_outcome: not_needed, rebooted, not_back or failed.
    • Honeypots: one at a time β€” runs get a rollout_position in name order; only the first is enqueued, each next one when the previous succeeded and (after a reboot) answers again. Anything else stops the rollout: the waiting runs are marked failed with the reason.
  • API: strategy takes the two new values; POST/DELETE /api/v1/honeypots/{id}/packages/{package}/hold, GET /api/v1/honeypots/{id}/packages/{package}/changelog.
  • Audit: honeypot.package.hold, honeypot.package.unhold, honeypot.update.reboot, honeypot.update.rollout_stopped.

πŸ•°οΈ History tab, notes, inventory export, saved log views

  • History (/honeypots/{id}/history, app.services.honeypot_timeline) merges what is already stored about the honeypot itself into one time line: notes (HoneypotNote), update runs, reachability transitions (found with a LAG() window over HoneypotReachabilitySample, never by loading every sample) and audit entries targeting the honeypot. Audit entries are for a superadmin only, like the audit log; read-only look-ups (*.view, *.browse, a manual *.refresh, following a log live) are left out, and a terminal's open/close pair is shown as one "terminal session" entry with its duration (honeypot.terminal.session). The audit log itself still has every entry. What attackers did is not here β€” that is the Activity tab and Events. 300 events at most per page; API: GET /api/v1/honeypots/{id}/timeline?days=&kind=.
  • Notes need write access to the honeypot; the author's name is stored as text so a note outlives the account. Audited as honeypot.note.add / .delete. API: POST /api/v1/honeypots/{id}/notes, DELETE .../notes/{note_id}.
  • Inventory export (/honeypots/inventory.csv) is a read-only report of every honeypot matching the list's current search and tags β€” unlike /honeypots/config/export, which is the structural import/export round trip. Audited as honeypot.inventory_export.
  • Bulk tags: POST /honeypots/bulk/tags/add and /remove from the bar under the list; honeypots the account may only read are skipped.
  • Saved log views (SavedLogView, app.services.saved_log_views) keep only the Logs tab's own filters, rebuilt from a fixed list of parameters, never the honeypot β€” a view is replayed on whichever honeypot's Logs tab it is picked from (and a view of OpenCanary's own log stores no path). Per account. API: /api/v1/account/saved-log-views.

🍯 The data model

  • Company β€” a tenant. One row per customer.
  • Honeypot β€” one deployed OpenCanary instance, attached to any number of companies via a plain link table, including zero (visible to a superadmin only). Identity + last-seen bookkeeping only; this app never connects to it proactively.
  • HoneypotEvent β€” one row per OpenCanary alert, close to OpenCanary's own JSON, with the common filter fields promoted to real columns. Scope is derived by joining through the honeypot's companies at query time, since a honeypot can belong to several.
  • CompanySnapshot β€” one row per company per day, backing the Dashboard's trend sparkline.

A company's own page can attach an already-existing honeypot or grant an already-existing user access, additively β€” never detaching either from wherever else they already are. Deleting a Company only ever removes links, never the honeypot or user account itself.

πŸ“‘ How events arrive: an SSH poll, nothing pushed

sequenceDiagram
    participant Beat as beat (scheduler)
    participant Worker as worker (Celery)
    participant Pi as Honeypot (Raspberry Pi OS/Debian/Ubuntu)
    participant DB as PostgreSQL

    Beat->>Worker: every opencanary_log_poll_interval_seconds
    Worker->>Pi: SSH: tail opencanary.log since last byte offset
    Pi-->>Worker: new alert lines (if any)
    Worker->>DB: store each as HoneypotEvent
    Worker->>DB: last_seen_at = now (proof OpenCanary answered at all)
Loading

OpenCanary has no built-in "POST to a URL" β€” it only logs locally. Every AppSettings.opencanary_log_poll_interval_seconds (Settings β†’ Checks & retention, default 120), this app connects over SSH and reads whatever's new in that log, incrementally by byte offset β€” no setup needed on the honeypot side beyond a pinned host key. Backs the Activity tab.

"OpenCanary reporting"/"OpenCanary silent" is last_seen_at vs. HONEYPOT_OFFLINE_AFTER_SECONDS, deliberately separate from is_reachable/last_ping_at (a plain SSH-plane ping β€” see the note on Home), which the pages call "SSH reachable"/"SSH unreachable". Neither is ever labelled just "online": the honeypot list, the honeypot's own page, the Dashboard and a company's page show both, side by side, each under its own name. A poll bumps last_seen_at on any successful contact with the log, not only when it found a real alert, so a quiet but healthy honeypot doesn't sit "offline" forever for lack of attacker traffic. The Monitoring tab's "OpenCanary service" panel is a third signal β€” systemctl is-active opencanary, piggybacked on the same round trip the CPU/RAM sample already makes.

πŸ™ˆ Ignored sources: expected visitors that should not raise alerts

A vulnerability scanner, a monitoring probe or an administrator's own workstation touches a honeypot the way an attacker does. Events β†’ Ignored sources (/events/ignored) is the list of such addresses (IgnoredSource, app.services.ignored_sources): one address or a CIDR network (nothing wider than /8, or /32 for IPv6), an optional note, and either one company or "every honeypot" (company_id NULL).

  • The event is still stored β€” with HoneypotEvent.ignored = true, set once when the poll writes it (networks_for(honeypot): the entries of each company the honeypot belongs to plus the ones for every honeypot). A flag on the row, not a join at read time, so every count stays one indexed filter.
  • It is quiet: no notification, no forward to syslog, and it is left out of the Dashboard, the Activity tab, the map and the daily company totals. The Events page hides it unless "Include ignored" is ticked (include_ignored=true in the API); a source address's page always shows everything that address did.
  • Changing the list re-marks stored events in the background (reapply_ignored_sources), per honeypot and per distinct address, so removing an entry makes its events count again.
  • Access: a company's entries need write access to that company, the entries for every honeypot a superadmin. Audited as event.ignore.add / event.ignore.remove. API: GET/POST /api/v1/events/ignored, DELETE /api/v1/events/ignored/{id}.

Three syslog targets, deliberately never mixed

  • Global (app.audit_syslog, Settings β†’ Integrations) β€” every AuditLogEntry. Never a honeypot alert. See Audit Log.
  • Per-company β€” one target per Company, that company's own Integrations tab. An alert from a honeypot shared across companies goes to every one of its companies' own targets. Never an audit entry.
  • Fleet-wide β€” "All honeypots" β†’ Integrations, not backed by any Company row. Every honeypot's alerts, in addition to the per-company target if both are set.

Split by company because this is multi-tenant β€” company A's SOC shouldn't see company B's traffic. All three share one transport (UDP/TCP/TCP-over-TLS) and one convention: the RFC 5424 MSG part is always a compact JSON object, never key="value" text. All best-effort β€” a delivery failure is logged and swallowed.

Wazuh users: wazuh/honeypotshelf_decoders.xml decodes this JSON and wazuh/honeypotshelf_rules.xml (repo root) ships one rule per OpenCanary module (rule ids 107000-107099), plus a brute-force correlation rule for repeated SSH login attempts against the same honeypot from the same source IP β€” see Audit Log for the same ruleset's audit-side coverage.

Settings β†’ Checks & retention: database-backed, not .env

Every background-check timeout/interval and every retention policy lives on AppSettings (Settings β†’ Checks & retention), not .env β€” a change applies without a restart for the timeouts/concurrency cap (read fresh from the database on every call), but the four sweep intervals (reachability/facts/monitoring/OpenCanary-log-poll) still only take effect on the next worker/beat restart, since a Celery beat_schedule entry's schedule is a plain value computed once at import time. Ported from an identical debcontrol change.

Event retention (how long OpenCanary events are kept) is one of those fields since 0.67.0: a number of days from 1 to 3650, applied at the next daily purge. Unlike the other retention fields it has no "keep forever" β€” leaving it empty falls back to EVENT_RETENTION_DAYS from .env (90 days unless changed), which is also what an instance upgraded from an older version keeps using until a value is saved. GET /api/v1/settings returns the value in force as event_retention_days; a change is audited as settings.event_retention.update.

πŸ—ΊοΈ GeoIP and the Map page

Resolves a HoneypotEvent's (or an AuditLogEntry's) source IP to a country/city/lat-long, once, at write time β€” not re-derived later, so a row's location stays historically accurate even after the database itself is updated. Deliberately only ever for a public IP: ipaddress.ip_address(...).is_global gates every lookup (app.services. geoip), since a company's own LAN-facing traffic or a login through an internal proxy has no real-world location to show, and would otherwise just waste a lookup MaxMind's database has no record for anyway.

Such an address is not left blank on the pages: Events, an event's detail and a source's page say "internal network" where a location would be (geoip_display.is_internal_address: RFC 1918, carrier-grade NAT, loopback, link-local, IPv6 unique-local), and the Map lists the ten most frequent internal sources under the world map β€” on an installation that only watches a LAN that table is the whole picture.

Needs a database this app never bundles (redistributing MaxMind's GeoLite2 data isn't allowed under their license) β€” Settings β†’ GeoIP takes a primary and backup download URL instead (both stored encrypted, same convention as every other secret in Settings, since a MaxMind "permalink" embeds your license key), tried in that order, never load-balanced. Not MaxMind-specific: any URL serving a compatible .mmdb β€” gzipped or not, raw or inside a .tar.gz β€” works, detected from the bytes themselves (app.services.geoip._extract_mmdb). A "Download now" button runs the same Celery task the periodic refresh (AppSettings.geoip_refresh_interval_hours, default weekly β€” MaxMind itself only updates GeoLite2 a couple of times a week) uses, so the two paths can never disagree on what "downloading" means. The downloaded bytes live in their own singleton table (GeoipDatabase), not on AppSettings itself β€” that row is read on essentially every request, so a multi-megabyte blob there would be a cost paid by every caller, not just GeoIP lookups.

The reader is cached in-process, revalidated against GeoipDatabase. updated_at at most once an hour β€” the overwhelming majority of calls (every audit log write, every ingested honeypot event) cost one in-memory check, not a database round trip, at the price of a freshly downloaded database taking up to an hour to be picked up by an already-running web/worker process.

The Map page (company-scoped exactly like the Dashboard) plots every located event on a real (if label-free) world coastline β€” traced once, offline, from Natural Earth's public-domain land outline, not a full political map with borders/place names (see app.services.geoip_display.WORLD_LAND_PATH). Pannable/zoomable β€” wheel, drag, pinch, or the +/βˆ’/reset buttons (app/web/static/js/map-zoom.js) β€” since a screenful of dots at world scale hides exactly the regional clustering a company with a lot of traffic from one area most wants to see. A top-countries table (flag emoji + name + count) sits below it either way. The audit log shows the same resolved country next to each entry's IP, deliberately excluded from the hash chain (AuditLogEntry.entry_hash) β€” it's a display enrichment, not part of the tamper-evident record of what actually happened.

Small but worth knowing

  • Repeated events share a row. Identical events in a row β€” same honeypot, type, source address and port, within a minute (SNMP and port scans arrive several to the second) β€” are one row marked "2Γ—" on Events and on a honeypot's Activity tab (event_search.group_repeats). Only the display folds; every event is stored, counted and exported.

  • A panel that refreshes the whole page does it quietly. The Dashboard, Map, Audit log and Companies panels are marked data-quiet-swap: quiet-swap.js drops a response identical to the previous one instead of swapping it in, and such a swap is never tinted. Small panels (a status badge, the update panel) still flash briefly when they change.

  • The honeypot list's bulk bar shows once a row is ticked (bulk-select.js, data-bulk-bar), with Restart / Shut down / Delete set apart. Under 760 px the table becomes one card per honeypot β€” CSS only, from each cell's data-label.

  • Time range on Monitoring and Activity. Five presets ending now, plus Custom range (From/To in the app's time zone). Dragging across any chart zooms into that stretch: the page reloads with it as the custom range (?start=&end=, monitoring_history.TimeWindow; at least 5 minutes, at most 366 days, never ending in the future). The tab's own auto-refresh, Refresh now and the Activity export all stay on that range. Same control and code as debcontrol's.

  • Monitoring and Logs look and work like debcontrol's. The Monitoring tab is a toolbar (time range, last check, Refresh now) over a two-column grid of chart cards β€” OpenCanary service, CPU, memory, disk usage (all mounts in one chart, with the disk-full forecast under it), disk I/O (read and write per disk), network (received and sent per interface), load, availability and connect latency β€” then the systemd services table, right on the page (failed units first, inactive ones hidden until asked for, filter box, sortable columns). Charts are server-rendered SVG from app/web/charts.py (ported verbatim from debcontrol β€” "nice" Y ticks, smooth lines, no chart library, nothing the CSP would block); hover, legend toggles and the table filters are static/js/monitoring-chart.js, which also keeps a typed filter across the panel's 30-second refresh. Refresh now forces a monitoring sample, a reachability check and a fresh services snapshot. The Logs tab opens with three source cards β€” System journal, Log file (an allowed path, typed or picked from the directory browser) and Honeypot (OpenCanary's own log on that device, where debcontrol has "Docker") β€” then a labelled filter toolbar and the viewer; ?source=journal|file|honeypot selects the card, and an older ?path= link still lands on the right one. The Activity tab uses the same toolbar and cards (events over time, events by type stacked, then sortable tables), and the Dashboard's trends are the same chart cards too β€” events per day, honeypots with OpenCanary reporting and honeypots with pending updates, one point per day summed over every company in view, plus the last 24 hours by alert type. There is one chart style in the app; debcontrol is the reference for all of it, and only colors and logos differ between the two.

  • Schedule time zones and the next-runs preview (ported from debcontrol): each scheduled task has its own IANA time zone (ScheduledTask.timezone, e.g. Europe/Prague), and its cron expression is read there β€” 0 3 * * * is 03:00 Prague time in summer and winter alike; across a daylight-saving change a time that doesn't exist runs at the next valid minute and one that happens twice runs once. A new schedule starts in the instance's TZ; a schedule saved before the option existed has none and keeps running in UTC. Under the cron field the form shows the next five runs live (htmx, GET /scheduling/cron-preview), in the task's zone and in server time when that differs, and says when the expression isn't valid yet. The list shows each schedule's zone. REST API: timezone on /api/v1/scheduling create/update and in every task, and GET /api/v1/scheduling/cron-preview?cron_expression=…&timezone=… (the next five runs as ISO timestamps; 400 for a bad expression or zone).

  • Logs tab: a quiet journal and live follow (ported from debcontrol). The journal is read as structured JSON (journalctl -o json, only the fields the tab needs), so each line is colored by its real journal priority; a plain log file gets a keyword-based guess instead (app/web/log_lines.py). Journal filters: Unit (-u, e.g. opencanary.service), Boot (this boot, the previous one β€” "what happened before the crash" β€” up to 20 back), Priority (-p), and Hide this app's own SSH sessions, which drops the sshd/PAM/logind/session-N.scope/ [email protected] lines and the SSH account's own sudo lines that this app's own logins cause, matched on journald's structured fields against the address and uid the honeypot itself reports for the connection (app.ssh.logs.filter_own_sessions); the tab says how many lines it hid. Every filter value is validated before it reaches the device. Follow live streams journalctl -f / tail -F over a WebSocket (/honeypots/{id}/logs/follow/ws, logs_ws.py), authenticated exactly like the terminal's socket (same-origin, network policy, session, write access, scope, pinned host key) before it is accepted, capped at an hour, audit-logged as honeypot.logs.follow/honeypot.logs.follow_end (never the content). REST API: priority, unit, boot and hide_own on GET /api/v1/honeypots/{id}/logs; a journal response adds entries (each line with its priority) and hidden. Live follow is web-only, the same as the interactive terminal.

  • Disk-full forecast: once an hour (Beat entry forecast-all-honeypot-disks, minute 20) one database-only job per monitored honeypot fits a least-squares line through each mount's used bytes over the last 7 days of monitoring samples and stores the extrapolation on Honeypot.disk_forecast (app/services/disk_forecast.py, ported from debcontrol). The Monitoring tab's disk usage shows "full in ~N days at this rate (+X GB/day)" per mount, and the REST API returns it as disk_forecast on GET /api/v1/honeypots/{id}. No estimate under 6 samples or 6 hours of history, for a shrinking or flat mount, or beyond ~10 years; a cleanup or log rotation resets the trend on the next run.

  • Alert type labels are localized for the two template-rendering call sites β€” the REST API, exports, and both syslog forwarders keep the plain English label on purpose, since a machine-consumed response shouldn't vary by session.

  • Live updates over WebSocket: a page opens one WebSocket and turns each {"kind": "..."} push into a DOM event, so its htmx panel refreshes within about a second instead of waiting out its poll interval β€” the poll stays only as a fallback for a missed/dropped push. Every message carries a kind only, never actual data, so the socket is a doorbell, not a feed: whatever it triggers is still fetched (and permission/scope-checked) exactly like its periodic poll already was. Four independent scopes share this mechanism (app/services/live_updates.py, app/web/routes/live_ws.py, app/web/static/js/live-updates.js):

    • Per-honeypot β€” Overview, Monitoring, Activity, Updates. The Monitoring and Activity tabs also have a "Refresh now" button.
    • Fleet-wide β€” the Dashboard and the Map page, refreshed the moment any honeypot's reachability or activity changes anywhere in view; and the honeypot list, which redraws its results (keeping ticked checkboxes and the bulk bar) when reachability or update availability changes β€” so a bulk "Check for updates" fills in as each honeypot finishes (live-list.js, at most every 5 s).
    • Admin-wide β€” the Audit log and the Companies list, refreshed the moment a new audit entry is written (which covers company/user create-edit-delete too, since those are always audit-logged). Superadmin-only, matching those pages.
    • Per-user β€” a user's own Notification history, refreshed the moment a send attempt (real or test) is logged for them.

    Deliberately not wired up on pages built around an in-progress form or a bulk-selection checkbox list (the Honeypots list, Users list, Scheduling) β€” an unannounced full-table swap mid-selection or mid-edit would silently discard whatever the operator was doing. Those keep their existing polling fallback only.

  • Update rollback: every real update run captures a package-version snapshot right before upgrading. "Roll back this update" diffs a fresh snapshot against that stored one and re-installs, pinned by exact version, only whatever actually changed since.

⚠️ **GitHub.com Fallback** ⚠️