← writeups

decoygrid dev log #002 — SQLite state and the polling loop

Following on from dev log #001 — persistent state is in. This is the first piece of the rebuild that makes decoygrid actually useful instead of just a polling demo.

The schema

Kept it deliberately small. Two tables:

CREATE TABLE honeypots (
    id          INTEGER PRIMARY KEY,
    name        TEXT NOT NULL UNIQUE,
    address     TEXT NOT NULL,
    port        INTEGER NOT NULL,
    last_up     DATETIME,
    last_down   DATETIME,
    status      TEXT NOT NULL DEFAULT 'unknown'
);

CREATE TABLE connection_attempts (
    id            INTEGER PRIMARY KEY,
    honeypot_id   INTEGER NOT NULL REFERENCES honeypots(id),
    source_ip     TEXT NOT NULL,
    occurred_at   DATETIME NOT NULL,
    raw_payload   BLOB
);

raw_payload stays as a blob rather than a parsed structure — different honeypot types capture wildly different data (a fake SSH banner exchange looks nothing like an HTTP request to a decoy admin panel), and trying to normalise that into columns now would mean redesigning the schema every time a new decoy type gets added. Parsing happens at read time, per protocol, not at write time.

The polling loop

func (g *Grid) pollLoop(ctx context.Context) {
    ticker := time.NewTicker(g.interval)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            var wg sync.WaitGroup
            for _, hp := range g.honeypots {
                wg.Add(1)
                go func(h *Honeypot) {
                    defer wg.Done()
                    g.checkAndRecord(h)
                }(hp)
            }
            wg.Wait()
        }
    }
}

Each honeypot gets checked concurrently within a tick rather than sequentially — with twenty honeypots and a one-second dial timeout each, sequential checking would mean a single tick could take up to twenty seconds in the worst case. Concurrent checking bounds it to roughly the slowest single check.

checkAndRecord does the actual dial attempt, updates last_up/last_down/status on success or failure, and that’s the entire write path for now. No batching, no write queue — SQLite handles this volume of writes without issue at the scale this runs at.

What’s next

The HTTP status endpoint is the next piece — a single /api/status route that reads the honeypots table and returns JSON. Once that exists, the web interface is just a template rendering that same data, and dev log #003 will cover it.