License: keep checking in after expiry (60-day window) so late renewals sync without re-ingesting the key #308

Closed
opened 2026-08-24 00:57:18 +00:00 by Cordy · 1 comment
Owner

Flagged by Nikola 2026-08-24, root-caused in internal/license/manager.go. The server already supports late-renewal re-sync (records kept forever; renewal re-signs the SAME license id; check-in 200 delivers the current key via installRefreshedKeyLocked). The client throws that away twice:

  1. checkinRequiredLocked() requires !m.lic.Expired(now) — the moment the key expires, the instance stops phoning home entirely. A renewal one day later is never picked up; the customer must re-paste the key.
  2. checkin() maps EVERY 403 to revoked=truest.Revoked = true → check-ins stop permanently. But the server 403s for expired too — so an instance that checks in during a 2-day payment hiccup marks itself revoked forever.

Fix

  • Depends on cairn-license-server#23's reason field on the 403 body (expired / revoked / unknown).
  • checkin(): parse the 403 body; only reason == "revoked" (or "unknown") sets st.Revoked. reason == "expired" (and, for old servers, a 403 with no reason when the local key is itself expired) is a new outcome: expired-but-may-renew.
  • checkinRequiredLocked(): also required when the license is expired but within expiredRetryWindow = 60 days of ExpiresAt and not revoked — i.e. keep trying (daily retry cadence already exists) for two months after expiry. On a 200, the refreshed key installs and everything resumes with zero admin action.
  • After 60 days of expired-and-never-renewed: stop (state quiesces; no eternal phoning from abandoned instances — this is also the privacy-correct behavior for a lapsed customer).
  • Enforcement is unchanged throughout: an expired license already doesn't count (licenseActive), so the extended check-ins change nothing about the seat cap — they only enable self-healing.
  • Admin UI (#admin/licensing): while in the window, show "License expired — checking for renewal until YYYY-MM-DD" instead of a dead-end expired state.
  • Tests: expired→renewal→auto-resync; 403-expired does not set Revoked; 403-revoked still does; window end stops the loop.

Docs handbook licensing page gains the behavior sentence: "If your subscription renews within 60 days of expiry, the instance picks the new key up on its own — you never re-enter it."

Flagged by Nikola 2026-08-24, root-caused in `internal/license/manager.go`. The server already supports late-renewal re-sync (records kept forever; renewal re-signs the SAME license id; check-in 200 delivers the current key via `installRefreshedKeyLocked`). **The client throws that away twice:** 1. `checkinRequiredLocked()` requires `!m.lic.Expired(now)` — the moment the key expires, the instance stops phoning home entirely. A renewal one day later is never picked up; the customer must re-paste the key. 2. `checkin()` maps EVERY 403 to `revoked=true` → `st.Revoked = true` → check-ins stop permanently. But the server 403s for *expired* too — so an instance that checks in during a 2-day payment hiccup marks itself revoked forever. ## Fix - Depends on cairn-license-server#23's `reason` field on the 403 body (`expired` / `revoked` / `unknown`). - `checkin()`: parse the 403 body; only `reason == "revoked"` (or `"unknown"`) sets `st.Revoked`. `reason == "expired"` (and, for old servers, a 403 with no reason **when the local key is itself expired**) is a new outcome: expired-but-may-renew. - `checkinRequiredLocked()`: also required when the license is expired but within `expiredRetryWindow = 60 days` of `ExpiresAt` and not revoked — i.e. keep trying (daily retry cadence already exists) for two months after expiry. On a 200, the refreshed key installs and everything resumes with zero admin action. - After 60 days of expired-and-never-renewed: stop (state quiesces; no eternal phoning from abandoned instances — this is also the privacy-correct behavior for a lapsed customer). - Enforcement is unchanged throughout: an expired license already doesn't count (`licenseActive`), so the extended check-ins change nothing about the seat cap — they only enable self-healing. - Admin UI (`#admin/licensing`): while in the window, show "License expired — checking for renewal until YYYY-MM-DD" instead of a dead-end expired state. - Tests: expired→renewal→auto-resync; 403-expired does not set Revoked; 403-revoked still does; window end stops the loop. Docs handbook licensing page gains the behavior sentence: *"If your subscription renews within 60 days of expiry, the instance picks the new key up on its own — you never re-enter it."*
Cordy closed this issue 2026-08-24 01:26:55 +00:00
Author
Owner

Shipped in v0.6.63 (PR #309), live on both dogfoods (cairn-enc @ .249, cairn-openbao @ .247, both 1/1 with 0 restarts). Window is 180 days per Nikola.

Server half deployed first (license-server#24: reason on the 403 + cancellation endpoints). End-to-end behavior now: expired license → daily check-ins continue up to 6 months → renewal re-signs the same id → next check-in installs the key, zero admin action. ForceCheckin reports the new ErrExpired instead of pretending the server is unreachable.

Dogfood checklist (light — the real path needs an expired license, which we can't stage without waiting):

  • Both instances show v0.6.63 and behave normally (login, files, peering still enabled — #306 regression check)
  • #admin/licensing unchanged for a healthy license
  • (When sandbox billing dress-rehearsal happens in v0.7: let a monthly sandbox license lapse, renew late, confirm the key self-installs — the one scenario only time can test)

Follow-up parked for the #302 admin polish pass: surface renewalWatchUntil ("License expired — checking for renewal until …") in the licensing panel; the Status API already exposes it.

**Shipped in v0.6.63** (PR #309), live on both dogfoods (`cairn-enc` @ .249, `cairn-openbao` @ .247, both 1/1 with 0 restarts). Window is **180 days** per Nikola. Server half deployed first (license-server#24: `reason` on the 403 + cancellation endpoints). End-to-end behavior now: expired license → daily check-ins continue up to 6 months → renewal re-signs the same id → next check-in installs the key, zero admin action. `ForceCheckin` reports the new `ErrExpired` instead of pretending the server is unreachable. **Dogfood checklist (light — the real path needs an expired license, which we can't stage without waiting):** - [ ] Both instances show v0.6.63 and behave normally (login, files, peering still enabled — #306 regression check) - [ ] `#admin/licensing` unchanged for a healthy license - [ ] (When sandbox billing dress-rehearsal happens in v0.7: let a monthly sandbox license lapse, renew late, confirm the key self-installs — the one scenario only time can test) Follow-up parked for the #302 admin polish pass: surface `renewalWatchUntil` ("License expired — checking for renewal until …") in the licensing panel; the Status API already exposes it.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/Cairn#308
No description provided.