Honeypot Management - sparkycz1/honeypot-shelf GitHub Wiki
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.
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.trustwith 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.
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.
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.
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.
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.
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, runningip link set β¦ address β¦on the default-route interface beforenetwork-pre.targetβ ahead of NetworkManager, systemd-networkd, ifupdown and dhcpcd alike, which all keep the address an interface already has. Deliberately not a.linkfile: 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).
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 updateexited non-zero, with what apt itself printed) is kept inHoneypot.updates_check_erroruntil 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 readsapt-mark showholdintoHoneypot.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 recordsreboot_outcome:not_needed,rebooted,not_backorfailed. -
Honeypots: one at a time β runs get a
rollout_positionin 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.
-
Reboot afterwards: only if the update needs it β after a successful
run the honeypot is checked (
- API:
strategytakes 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 (
/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 aLAG()window overHoneypotReachabilitySample, 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'sopen/closepair 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 ashoneypot.inventory_export. -
Bulk tags:
POST /honeypots/bulk/tags/addand/removefrom 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.
-
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.
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)
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.
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=truein 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}.
-
Global (
app.audit_syslog, Settings β Integrations) β everyAuditLogEntry. 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
Companyrow. 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.
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.
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.
-
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.jsdrops 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'sdata-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 arestatic/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|honeypotselects 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'sTZ; 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:timezoneon/api/v1/schedulingcreate/update and in every task, andGET /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 ownsudolines 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 streamsjournalctl -f/tail -Fover 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 ashoneypot.logs.follow/honeypot.logs.follow_end(never the content). REST API:priority,unit,bootandhide_ownonGET /api/v1/honeypots/{id}/logs; a journal response addsentries(each line with its priority) andhidden. 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 onHoneypot.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 asdisk_forecastonGET /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 akindonly, 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.