Skip to content

Commit 9b8fcd9

Browse files
authored
fix: bound the window in which the shared key still works for a worker (#233)
Per-worker fleet keys exist so CASHPILOT_API_KEY stops being enough to speak as any particular worker. The cutover finalized when a worker first heartbeat with its own key -- and for a worker that CANNOT persist that key, it never did. A pre-1.0.0 image, a read-only /data, an ephemeral container: the shared key stayed valid for that identity forever, the UI re-sent the per-worker key to whoever held the shared one every 60 seconds, and the only trace was one log line a minute. The window now also closes 24 hours after the key is minted. Past it the shared key is refused for that worker and the fleet page marks it "enrollment incomplete", with what causes it and how to recover. Absent is not expired: a missing or unparseable issue time reads as still enrolling, and the migration backfills existing unconfirmed workers to now, so this upgrade cannot take a mid-enrollment fleet offline. docs/upgrade-v1.md claimed an old worker "can no longer heartbeat", which is why nobody went looking for one still on the shared key. It now says what happens.
1 parent 70ea57b commit 9b8fcd9

6 files changed

Lines changed: 488 additions & 7 deletions

File tree

app/database.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,7 @@ def decrypt_value(value: str) -> str:
385385
last_heartbeat TEXT,
386386
api_key_enc TEXT,
387387
key_confirmed INTEGER NOT NULL DEFAULT 0,
388+
key_issued_at TEXT,
388389
registered_at TEXT NOT NULL DEFAULT (datetime('now'))
389390
);
390391
@@ -740,6 +741,20 @@ async def init_db() -> None:
740741
await db.execute("ALTER TABLE workers ADD COLUMN api_key_enc TEXT")
741742
if "key_confirmed" not in cols:
742743
await db.execute("ALTER TABLE workers ADD COLUMN key_confirmed INTEGER NOT NULL DEFAULT 0")
744+
if "key_issued_at" not in cols:
745+
# When the per-worker key was minted, so the window in which the
746+
# SHARED key still works for that worker can be bounded.
747+
await db.execute("ALTER TABLE workers ADD COLUMN key_issued_at TEXT")
748+
# Backfilled to NOW, not to NULL and not to the distant past.
749+
# Every already-enrolled-but-unconfirmed worker would otherwise be
750+
# instantly past its window the moment this upgrade lands, and a
751+
# patch release would take a working fleet offline with no warning.
752+
# Absent is not expired: these get a full fresh window, and only a
753+
# worker that still cannot confirm within it is cut off.
754+
await db.execute(
755+
"UPDATE workers SET key_issued_at = datetime('now') "
756+
"WHERE api_key_enc IS NOT NULL AND api_key_enc != '' AND key_confirmed = 0"
757+
)
743758

744759
# Migrate earnings table: add fx_rate_usd so a non-USD balance's value at the
745760
# time it was recorded stays reconstructable (rates are only cached live).
@@ -1632,7 +1647,7 @@ async def set_worker_key(client_id: str, key: str) -> None:
16321647
db = await _get_db()
16331648
try:
16341649
cursor = await db.execute(
1635-
"UPDATE workers SET api_key_enc = ?, key_confirmed = 0 WHERE client_id = ?",
1650+
"UPDATE workers SET api_key_enc = ?, key_confirmed = 0, key_issued_at = datetime('now') WHERE client_id = ?",
16361651
(encrypt_value(key), client_id),
16371652
)
16381653
await db.commit()
@@ -1711,6 +1726,27 @@ async def get_worker_key_state(client_id: str) -> tuple[str | None, bool]:
17111726
await db.close()
17121727

17131728

1729+
async def get_worker_key_issued_at(client_id: str) -> str | None:
1730+
"""When this worker's per-worker key was minted, or None if not recorded.
1731+
1732+
Separate from ``get_worker_key_state`` rather than widening its tuple: that
1733+
function is called on every heartbeat and its 2-tuple contract is relied on
1734+
in several places, while this is only needed on the one branch that decides
1735+
whether the shared key may still be honoured.
1736+
1737+
None means UNKNOWN — a row written before this column existed and missed by
1738+
the migration's backfill. Callers must not read it as "long ago"; unknown is
1739+
not expired.
1740+
"""
1741+
db = await _get_db()
1742+
try:
1743+
cursor = await db.execute("SELECT key_issued_at FROM workers WHERE client_id = ?", (client_id,))
1744+
row = await cursor.fetchone()
1745+
return row["key_issued_at"] if row else None
1746+
finally:
1747+
await db.close()
1748+
1749+
17141750
# --- Health Events ---
17151751

17161752

app/main.py

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3459,14 +3459,67 @@ def _bearer_token(request: Request) -> str:
34593459
return h[7:] if h.startswith("Bearer ") else ""
34603460

34613461

3462+
WORKER_KEY_CONFIRM_WINDOW = timedelta(hours=24)
3463+
3464+
3465+
def _humanize_window() -> str:
3466+
hours = WORKER_KEY_CONFIRM_WINDOW.total_seconds() / 3600
3467+
return f"more than {hours:g} hours" if hours >= 1 else f"more than {WORKER_KEY_CONFIRM_WINDOW.total_seconds():g}s"
3468+
3469+
3470+
def enrollment_state(key_issued_at: str | None, confirmed: bool, now: datetime | None = None) -> str:
3471+
"""``"confirmed"``, ``"pending"`` (still inside the window) or ``"incomplete"``.
3472+
3473+
Pure, so the heartbeat guard and the fleet page cannot disagree about which
3474+
workers are still enrolling and which have stalled.
3475+
3476+
An UNPARSEABLE or missing timestamp reads as ``"pending"``. That is the safe
3477+
direction: reading unknown as expired would lock out a worker on the
3478+
strength of a value nobody wrote.
3479+
"""
3480+
if confirmed:
3481+
return "confirmed"
3482+
if not key_issued_at:
3483+
return "pending"
3484+
try:
3485+
issued = datetime.fromisoformat(key_issued_at).replace(tzinfo=UTC)
3486+
except (TypeError, ValueError):
3487+
logger.warning(
3488+
"Worker key issue time %r is unparseable; treating enrollment as still in progress", key_issued_at
3489+
)
3490+
return "pending"
3491+
return "pending" if (now or datetime.now(UTC)) - issued <= WORKER_KEY_CONFIRM_WINDOW else "incomplete"
3492+
3493+
3494+
async def _enrollment_window_open(cid: str) -> bool:
3495+
"""Whether the shared key may still be honoured for this enrolled worker.
3496+
3497+
A second read of the same row, rather than widening ``get_worker_key_state``.
3498+
It is reached only on the rare branch — a worker that has a key, has never
3499+
used it, and is presenting the shared key. Every steady-state heartbeat
3500+
returns "ok" before this line, so this is not on the hot path.
3501+
"""
3502+
return enrollment_state(await database.get_worker_key_issued_at(cid), confirmed=False) == "pending"
3503+
3504+
34623505
async def _authenticate_worker_heartbeat(request: Request, cid: str) -> str:
34633506
"""Authenticate a heartbeat and classify it. Returns one of:
34643507
34653508
- ``"enroll"`` — worker has no key yet and presented the shared key: mint one.
34663509
- ``"reissue"`` — worker has a key that is NOT yet confirmed and presented the
34673510
shared key: it likely lost the enrollment response, so re-deliver the SAME
3468-
key (until confirmed, the shared key still works for this one worker — a
3469-
bounded window that closes on the worker's first own-key heartbeat).
3511+
key. Until confirmed, the shared key still works for this one worker — a
3512+
window that closes on the worker's first own-key heartbeat OR after
3513+
``WORKER_KEY_CONFIRM_WINDOW`` from the key being minted, whichever comes
3514+
first.
3515+
3516+
That second bound is the whole point of this function. The window used to
3517+
close only on confirmation, so a worker that CANNOT persist its key — a
3518+
pre-1.0.0 image, a read-only /data, an ephemeral container — never closed it
3519+
at all. The security cutover the per-worker keys exist for silently never
3520+
completed: anyone holding CASHPILOT_API_KEY could impersonate that worker
3521+
indefinitely, and the UI re-sent the key to them every 60 seconds while
3522+
logging "not yet confirmed" once a minute forever.
34703523
- ``"ok"`` — worker presented its own key: authenticated; confirm it so the
34713524
shared key is refused from now on (the cutover finalizes).
34723525
@@ -3488,7 +3541,28 @@ async def _authenticate_worker_heartbeat(request: Request, cid: str) -> str:
34883541
if token and hmac.compare_digest(token.encode(), key.encode()):
34893542
return "ok"
34903543
if not confirmed and shared_ok:
3491-
return "reissue"
3544+
if await _enrollment_window_open(cid):
3545+
return "reissue"
3546+
# Past the window. Refusing here is the cutover finally completing for a
3547+
# worker that could never complete it itself. Said plainly, because the
3548+
# operator's next question is why a worker that was working stopped.
3549+
logger.warning(
3550+
"Worker '%s' presented the SHARED key %s after enrolling and has never used its own key. "
3551+
"The shared key is no longer accepted for it: a worker that cannot persist "
3552+
"/data/.worker_key would otherwise keep the shared key valid for its identity forever. "
3553+
"Upgrade that worker to 1.0.0+, give it a writable and PERSISTENT /data, then remove it "
3554+
"in the fleet page so it can enroll again.",
3555+
cid,
3556+
_humanize_window(),
3557+
)
3558+
raise HTTPException(
3559+
status_code=401,
3560+
detail=(
3561+
"Enrollment was never completed for this worker and the shared key is no longer "
3562+
"accepted for it. Upgrade the worker to 1.0.0+ with a writable, persistent /data, "
3563+
"then remove it in the fleet page so it can enroll again."
3564+
),
3565+
)
34923566
raise HTTPException(status_code=401, detail="Invalid or missing per-worker key")
34933567

34943568

@@ -3565,6 +3639,11 @@ async def api_list_workers(request: Request) -> list[dict[str, Any]]:
35653639
raw_watts = config.get(f"worker_{_worker_config_key(w)}_watts") or config.get(f"worker_{w.get('id')}_watts")
35663640
w["watts"] = raw_watts or ""
35673641
w["dedicated"] = _worker_flag(config, w, "dedicated")
3642+
# Whether this worker ever finished the per-worker-key cutover. Without
3643+
# it the only trace was a UI log line once a minute, which nobody reads,
3644+
# so a worker still authenticating with the SHARED key looked identical
3645+
# to a fully enrolled one on the fleet page.
3646+
w["enrollment"] = enrollment_state(w.get("key_issued_at"), bool(w.get("key_confirmed")))
35683647
return workers
35693648

35703649

app/templates/fleet.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,11 @@ <h2 class="section-title">Workers</h2>
310310
<span style="font-weight: 600; color: var(--text-primary);">${esc(w.name)}</span>
311311
${isAndroid ? '<span style="font-size:0.7rem; padding:2px 6px; border-radius:4px; background:var(--bg-hover); color:var(--text-muted);">Android</span>' : ''}
312312
${nameCounts[String(w.name || '')] > 1 ? `<span style="font-size:0.7rem; padding:2px 6px; border-radius:4px; background:var(--warning-soft, var(--bg-hover)); color:var(--warning);" title="More than one worker is registered under this name. That usually means this host was redeployed with a new or empty /data, so it re-registered under a new id and the old row stayed behind. Compare the ids and the last-seen times before removing one.">duplicate name</span>` : ''}
313+
${w.enrollment === 'incomplete'
314+
? `<span style="font-size:0.7rem; padding:2px 6px; border-radius:4px; background:var(--warning-soft, var(--bg-hover)); color:var(--warning);" title="This worker enrolled but never used its own key, so it is still authenticating with the shared CASHPILOT_API_KEY. The shared key is no longer accepted for it. It usually means the worker cannot persist /data/.worker_key — an old image, or a /data that is read-only or not a persistent volume. Upgrade it to 1.0.0+ with a writable persistent /data, then remove it here so it can enroll again.">enrollment incomplete</span>`
315+
: w.enrollment === 'pending'
316+
? `<span style="font-size:0.7rem; padding:2px 6px; border-radius:4px; background:var(--bg-hover); color:var(--text-muted);" title="This worker has been issued its own key but has not used it yet, so the shared key still works for it for a short while. It clears itself on the worker's next heartbeat.">enrolling</span>`
317+
: ''}
313318
<code style="font-size:0.68rem; color:var(--text-muted); background:var(--bg-hover); padding:1px 5px; border-radius:3px;" title="This worker's client_id — the id CashPilot keys it on. Two rows with the same name always have different ids; this is how to tell which is which.">${esc(w.client_id || ('#' + w.id))}</code>
314319
</div>
315320
${_isOwner ? `<button class="btn btn-danger btn-sm btn-remove-worker" data-worker-id="${w.id}" data-worker-name="${esc(w.name)}" data-worker-client="${esc(w.client_id || '')}" title="Remove worker">Remove</button>` : ''}

docs/upgrade-v1.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,17 @@ You don't handle keys by hand — this all happens on the next heartbeat.
3232

3333
1. **Upgrade the UI** image to `drumsergio/cashpilot:1.0.0` (or newer).
3434
2. **Upgrade every worker** image to `drumsergio/cashpilot-worker:1.0.0` (or newer).
35-
Do not leave old-version workers running against a v1.0.0 UI — once the UI has
36-
enrolled a worker, an old worker image (which only knows the shared key) can no
37-
longer heartbeat.
35+
Do not leave old-version workers running against a v1.0.0 UI. An old worker
36+
image only knows the shared key and cannot persist the one it is issued, so it
37+
never finishes enrolling: for the first 24 hours it keeps heartbeating on the
38+
shared key — which means anyone holding `CASHPILOT_API_KEY` can impersonate it
39+
for that long — and after that it is refused and goes offline. The fleet page
40+
marks such a worker **enrollment incomplete**.
41+
42+
The same thing happens to a current worker image whose `/data` is read-only or
43+
is not a persistent volume, because it has nowhere to keep `/data/.worker_key`.
44+
To recover one: fix the image or the volume, then remove the worker in the
45+
fleet page so it enrolls again from scratch.
3846
3. Keep `CASHPILOT_API_KEY` **unchanged** — it is still needed for enrollment.
3947
4. Restart the containers. Each worker auto-enrolls on its first heartbeat; confirm
4048
every worker shows **online** in the fleet dashboard.

0 commit comments

Comments
 (0)