29 Home
cairn-ci edited this page 2026-09-09 22:38:03 +00:00
This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

2026-09-05: v0.6.124v0.6.131 — preview converter arc (#297), #310 chart items, #346 closed, seats honesty (#426), seat lifecycle (#428). One day, four issues, eight releases, all TDD with witnessed reds on the runner. #297 (closed): the "docx broken" report was a session wipe plus misleading copy, not a regression — fixed honest error states + scale-to-fit (v0.6.124), then the optional Gotenberg preview converter (v0.6.125): server-side PDF conversion for legacy office formats, version-keyed bounded cache with single-flight, converter deployed on the Pis (cairn-converter ClusterIP, GitOps), Helm chart + example compose ship it by default (batteries included; bare binary stays converter-less), full trust-model docs. #310: Manuel's chart items shipped as v0.6.127 — Ingress + Gateway API HTTPRoute templates, nodeSelector/tolerations/affinity for both pods, listen.port driving CAIRN_LISTEN (config Secret needs no listen), plus CAIRN_ADMIN_USERNAME/PASSWORD_HASH boot upsert. #346 (closed): full joint verification of both dogfoods incl. Keycloak; found + trimmed the over-privileged keycustody client and the admin-client-row-lies gap. #426 (v0.6.128): admins occupy seats (recorded, never denied — honestly over the cap), 14-key tn() singular sweep, admin client Failing third state. #428 (v0.6.129131): seat lifecycle — a seat is a person who signed in within the last 90 days; sign-ins refresh last-seen (throttled daily), aged-out seats free themselves, returnees claim like newcomers; Users & access grew Access|Seats tabs with per-holder release (audited seat-release, reversible by nature); licence meter footer states the window and links over. Decisions recorded on #428 incl. the sovereignty call: no extra metadata in the check-in payload (docs-only enforcement); parked: whether to drop seatsUsed from check-in entirely. Ops lessons (wiki'd in homelab-config Troubleshooting): git --no-pager diff in one-shots (a pager hung a run; pkill less recovered it), browser tooling uses https://forge.c0rdyceps.ch never the raw IP, substring-count guards must count attribute bytes. NOTE: v0.6.56v0.6.123 have no entries here — work from other sessions; backfill from CHANGELOG.md if the gap ever matters.

2026-08-20: #243 shipped (v0.6.55) — opt-in live expansion of group receivers; bao gets the IdP read client. The dogfood finding (nikola-test, member of cairn-admins in bao's group-based receive allow-list, never appeared in enc's send picker — "No listed recipients") was the designed #125 degrade: group receivers were never expanded. v0.6.55 implements the ratified #234 decision D: receiver-side opt-in switch "Suggest group members to peers" (settings.peeringExpandGroups, default off, Peering → Global), live expansion via the new Manager.GroupMembers (Keycloak /groups/{id}/members through the read client — one group at a time, never Manager.Users, never materialized), every suggested name still passing the exact Recipients.Exists delivery gate; peer-recipient-list audit gains " expanded"; LDAP member lookup is a noted follow-up (degrades to free-text). Instance config change (untracked Secret): cairn-openbao-config gained an auth.groups block — Keycloak client cairn-bao-groups (confidential, service accounts, realm-management view-users + query-groups only, manage off) — plus the toggle switched on; verified live: enc's picker now lists nikola-test under "Listed recipients at this peer". Side effects on bao: live group resolution (#96) and the #129 group-only-receivers warning resolved. Ops note along the way: the Mac had no kubeconfig (kubectl fell back to localhost:8080); the admin kubeconfig procedure is now documented in Wiki.js homelab/k3s ("Cluster access from the Mac").

2026-07-25: encryption reframed as an open fork + post-quantum correction. (Also re-records the encryption status — an earlier wiki entry was lost to a master/main branch mixup in the docs-patch workflow.) The age at-rest encryption probe is shipped and verified live (v0.2.3v0.2.4): internal/storage/encrypt age decorator around storage.Driver, per-user keys in the Keycloak profile (cairnAgeIdentity, keyCustody keycloak-profile), encrypt-to-[user, recovery], transparent to REST/WebDAV; content-only; server-side (not client E2E). Correction (verified against age README + pq.go): age IS post-quantum-capable — v1.3.0+ ships hybrid ML-KEM-768 + X25519 (age-keygen -pq; the Go library exports GenerateHybridIdentity/ParseHybridIdentity/ParseHybridRecipient), and we pin v1.3.1, so PQ is a config toggle, not a new stack. We ship classical X25519 today; the earlier "not post-quantum" note was wrong. Constraint: a Hybrid recipient can't mix with classical, so the recovery recipient must also be Hybrid. Open fork (Manuel, 2026-07-24): key custody splits into two orthogonal questions — (A) where keys live uniformly across Keycloak+AD (Keycloak profile today; OpenBao transit as the uniform option; AD unresolved), and (B) can the backend avoid holding a usable key (Manuel's passwordHash + PIN derivation — RAM-only, zeroed after use — which works for local/LDAP but is impossible under OIDC/app-passwords; the E2E trade relocated). OpenBao answers A, not B. Also on the table: demote the single deployment key + add a per-instance seed (blast radius); group dirs via age native multi-recipient; a separate --target=crypto role for independent crypto scaling (Manuel's actual reason for a separate service); and a Datentresor (encrypted-vault folder) fallback if whole-backend transparent encryption proves too heavy. Decision record: ENCRYPTION-COMPARISON.md (rewritten as the open fork). Issues #15, #28#33. Two independent prototypes (Manuel's + an isolated spike of ours) feed the decision before we commit — the OIDC-fork playbook. PQ + custody are v0.4; quota enforcement (#14) + seekable ranged reads (#15) finish v0.3.

2026-07-24 (later): consolidated to a single canonical dogfood. Trimmed the three-instance dogfood setup to one: cairn-enc at .249 (S3/Garage bucket cairn-enc + OIDC/Keycloak + age encryption, keyCustody keycloak-profile) is now the only running Cairn — the "actual product" config, ahead of external exposure. Retired cairn (.247, POSIX/NFS, local auth) and cairn-s3 (.250, S3 plaintext): manifests removed from homelab-config k3s/cairn/ (deployment.yaml/service.yaml/storage.yaml/s3-dogfood.yaml) and the live objects kubectl deleted (Argo prune is off, so removing from git alone does not reap them). The cairn-data NFS PV was Retain, so DaPool/cairn-data on TrueNAS is left intact (delete there to reclaim space). ServiceMonitor cairn repointed from name=cairn to part-of=cairn so up{namespace="cairn"} (and the CairnDown/CairnScrapeMissing alerts) keeps tracking the surviving instance. The coverage the retired instances added (POSIX ranged reads; S3-plaintext ranged reads — note the encrypted path currently does full-object reads) now lives only in unit/conformance tests + the on-demand interop-s3.yml/interop-enc.yml workflows; re-spin from the kept manifests if a standing instance is ever needed again. Deferred cosmetic: the survivor is still named cairn-enc at .249 — renaming to plain cairn (and moving to .247 or straight to files.c0rdyceps.ch) folds into the external-exposure step, which touches OIDC redirect URIs / Caddy anyway. NOTE: the "Test infrastructure" and older "Status" sections below predate this consolidation and still list three instances — this entry supersedes them.

2026-07-21: S3 driver verified as a live dogfood against Garage. Stood up a second dogfood instance cairn-s3 at .250 (storage.driver: s3, v0.2.1, auth.mode: local) backed by a live Garage bucket (cairn-dogfood on the TrueNAS Garage at 192.168.10.32:3900), and ran interop-s3.yml: WebDAV over the hand-rolled SigV4 client — PROPFIND 207, wrong-password 401, MKCOL 201 (writes the S3 directory-marker object), PUT 201, GET byte-match, PROPFIND Depth 1 lists the object, DELETE file 204, DELETE dir 204 (recursive prefix delete). The previously env-gated conformance suite (SeaweedFS) now has a live-deployment proof against the actual dogfood target (Garage). Config lives in a cairn-s3-config k8s secret (endpoint/bucket/region + Garage key + the reused nikola passwordHash, so the existing CAIRN_TEST_PASSWORD actions secret works unchanged); /data is emptyDir scratch for the TUS spool only (interrupted uploads don't survive a restart on this instance — fine for a test dogfood). Closes ARCHITECTURE.md §9's "a second instance on the S3 driver against Garage remains planned". Garage setup was docker exec into the Garage container: garage bucket create + key create + bucket allow --read --write; region must match Garage's s3_region (default garage) or SigV4 403s.

2026-07-21: v0.3 underway — quota (reporting + WebDAV props), admin API. Three v0.3 code increments merged via feat/* → PR → squash: quota reporting (GET /api/v1/quota, feature-detected storage.Usager; POSIX statfs, S3 → 501; PR #8), WebDAV RFC 4331 quota props (D:quota-available-bytes/D:quota-used-bytes on collections so Finder shows free space; PR #9), and the admin API (auth.admins username allow-list working across local/OIDC/LDAP; GET /admin/info, GET /admin/shares = all links, DELETE /admin/shares/{token} = revoke any; PR #10). Quota reporting + WebDAV props shipped in release v0.2.1 and the .247 dogfood was rolled to it (Finder PROPFIND 207 confirmed serving the props live). Workflow note: minor/patch tags are now cut autonomously (owner-approved); CI runs on pull_request (not branch push).

2026-07-21: Active Directory live-bind verified — LDAP fully proven. Stood up a throwaway Samba AD DC on k3s (k3s/samba-ad/, plain Debian + boot-time samba-tool domain provision, arm64) + a cairn-ad-test instance at .250 (auth.mode: ldap, LDAPS to samba.samba-ad.svc:636, ldapUserFilter: (sAMAccountName=%s), ldapInsecureSkipVerify for the self-signed cert), and ran interop-ad.yml: WebDAV Basic as the AD user ldaptestsearch-then-bind over LDAPS against live Samba AD → PROPFIND 207, PUT 201, GET byte-match, DELETE 204; wrong-password and unknown-user both 401. Both the OpenLDAP uid and the Active Directory sAMAccountName/LDAPS paths are now live-verified — AD support is proven, not merely claimed. Samba-on-arm64-in-k8s gotchas (all fixed, in order): domain provision needs the samba-ad-provision (schema LDIFs), samba-dsdb-modules (samba_secrets LDB module) and samba-vfs-modules (acl_xattr) packages; writing the sysvol security.NTACL xattr needs CAP_SYS_ADMIN; the AD DC forks /usr/sbin/winbindd at startup so the winbind package must be installed too (else "Failed to exec child" → exit 255); and because the container rootfs is ephemeral while the domain DB lives on an emptyDir, the provisioned /etc/samba/smb.conf must be persisted into the volume and restored on restart — otherwise a reinstalled container boots with the default server role = standalone and exits with "misconfigured server role" (error 22) while the provision marker suppresses re-provisioning. Torn down after: k3s/samba-ad/ + k3s/cairn/test-instance-ad.yaml deleted from git; kubectl delete ns samba-ad + the cairn-ad-test objects + secrets.

2026-07-20 (night): v0.2 milestone COMPLETE. All three remaining v0.2 features landed on main, CI green (run 174, commit 8a091e0): LDAP provider (internal/auth/ldapauth, go-ldap search-then-bind, auth.mode: ldap, filter-escaping + tests), TUS 1.0.0 resumable uploads (internal/tus at /api/v1/tus/, spool + JSON sidecar surviving restarts), and the app-password management UI (feature-detected panel in the embedded web UI, mirrors the shares UI). Earlier today the OIDC real-IdP e2e PASSED against Keycloak on k3s (browser Code+PKCE login → /api/v1/me={"username":"nikola-test"}; app-password WebDAV PROPFIND 207 / PUT 201 / GET 200 / wrong-secret 401 / DELETE 204; pod 0 restarts). Keycloak lives at 192.168.10.248 (homelab-config k3s/keycloak/), Cairn OIDC test instance v0.0.3 at 192.168.10.249.

2026-07-21: TUS real-client verified. Cut release v0.2.0 (ko multi-arch, run 174→image), rolled the dogfood at .247 to it, and ran the new interop-tus.yml (manual dispatch, LAN): eventials/go-tus created a 12 MiB upload in 2 MiB chunks, was interrupted at 4 MiB, resumed from the server's HEAD-reported offset (exactly 4194304), finished, and the stored file was byte-identical (then DELETE 204).

2026-07-21: LDAP live-bind verified — v0.2 fully verified. Deployed a throwaway OpenLDAP (k3s/openldap/, bitnamilegacy/openldap, declaratively seeded uid=ldaptest) + a cairn-ldap-test instance at .250 (auth.mode: ldap, search-then-bind against ldap.openldap.svc), and ran interop-ldap.yml: WebDAV Basic as ldaptestsearch-then-bind against live OpenLDAP → PROPFIND 207, PUT 201, GET byte-match, DELETE 204; wrong-password and unknown-user both 401. Every v0.2 feature now has a real-world verification. Two gotchas hit and fixed: (1) Bitnami emptied its Docker Hub bitnami/ images in the 2025 deprecation (bitnami/openldap:latest 404s) → use bitnamilegacy/openldap; (2) the deprecated LDAP_USER_DC + LDAP_GROUP env collided on ou=users, so tree-seeding added it twice → exit 68 (LDAP entryAlreadyExists) → CrashLoopBackOff → drop both (users default to ou=users anyway).

Cairn — Project Wiki

What: Self-hosted file storage, single Go binary. Web UI + WebDAV + pluggable storage (POSIX/S3) + pluggable identity (OIDC + LDAP implemented). "A better Nextcloud without the bloat" — see ARCHITECTURE.md in the repo for the full design; this wiki holds the working log, decisions, and gotchas.

v0.6.143-146 — 2026-09-09/10: first live Nextcloud federation + the SHA-512 hunt

First successful Nextcloud → Cairn OCM share, after a three-release diagnosis (#454): NC 34 signs OCM requests with SHA-512 digests and RSA-SHA512 signatures over "(request-target) content-length date digest host", against its own SHA-256 defaults; NC receivers accept broken signatures unless enforcement is on, so the monoculture never noticed. v0.6.143 made digest refusals carry their evidence (header, computed, sizes); v0.6.144 logged every OCM refusal server-side (the sender may swallow the body — NC surfaces it only at debug level); the capture named SHA-512; v0.6.145 verifies both dialects and rebuilds the signing base from the declared header list, host taken from the request authority (HTTP/2-safe), with a byte-faithful NC request as the permanent regression test. v0.6.146 aligned the Federated view with its approved mockup (fedcards, amber non-contact warning, contacts table) and replaced the nav dot with real bell notifications (#459, #460): derived kind ocm-share from the pending ledger, Cairn-peer/Inbox copy for peering deliveries, click-throughs to Requests and Inbox. Dev lesson re-learned twice: post-condition counts must include comment mentions and same-line repeats.

v0.6.142 — 2026-09-08: OCM Servers policy control + live Status

Dogfood finding on the v0.6.141 table: the policy chip mutated on a single click (accident-prone, not identifiable as a setting) and the mockup Status column was missing (reachability had been deferred to the metrics pass). The policy is now a per-row dropdown that applies nothing on change; it arms a pane-local Apply/Discard bar that names the consequence in words. Opening the tab probes each allowed server in parallel via the existing check endpoint: nothing stored, nothing audited, no logs accumulate; the continuous gauge and Prometheus alert stay with the metrics pass. Frontend-only; .ocmchip removed entirely.

2026-09-08 — v0.6.139: Open Cloud Mesh, and the mesh speaks back (#117, PR #448)

Cairn now federates with Nextcloud, ownCloud, OpenCloud, Seafile and CERNBox over standards-track OCM - bidirectionally, off by default, and behind an allow-list that actually gates both directions (with per-server invited-contacts-only strictness, relaxable per peer, stamped and audited). Hand-rolled internal/ocm on stdlib crypto: dual-dialect discovery incl. ownCloud quirks, RFC 9421 + draft-cavage signatures, invites both roles, dialect-adaptive sending, a bearer-jailed /dav/ocm/ grant surface riding the audited/decrypting driver stack, and refusals that name their reason on the wire. Users get the Federated view (federations as folders, live remote browsing, explicit save-a-copy, Requests with badge, user-owned Contacts) and a federated mode in the Share dialog; admins get the new Federation rail section with the OCM topic. Black-box verified by the kept ocm-interop.yml harness booting the real binary. See OCM-Federation for the full flowchart; live NC interop test pending operator secrets.

2026-09-07 — v0.6.138: leak tracing's first dogfood findings within the hour (PR #446)

Nikola enabled marking on bao minutes after v0.6.137 landed and found two blemishes: the Trace drop zone rendered background-on-background (.tracedrop had no background on the --snow page, and its hover state was also --snow — doubly invisible; now a --surface card with --lichen hover, as the approved mockup had it), and the Trace tab only appeared after leaving and re-entering the page, because saveSettings never re-checked marks state — refreshMarksTab() now runs on save success, so enabling the toggle reveals the tab immediately, no reload. The v0.6.138 changelog also carries the EULA introduction line owed from docs run 2.

2026-09-07 — Docs run 2 + the product grows its terms of use (PR #445)

Second docs consistency run (planned as second-to-last before launch). New root EULA.md (draft v0.1, same pending-legal-review banner as LICENSE.md): use-terms for both tiers alongside LICENSE.md's source rights — Paddle as merchant of record, the exact data boundary (keyless = silent; licensed = licence id only, server-side check-in/refusal records disclosed per #40), operator-is-controller, the lawful-use clause for optional monitoring features answering the #423 vendor-accountability question, Swiss-law liability cap with the art. 100 CO carve-out, termination-to-free-tier, and a maintenance note to swap in the GmbH name on register entry. Consistency splices: README (leak tracing, Remove licence, EULA link), admin-policy (Trace deep links, #442 picker + #433 registry, marking subsection), SYNC.md (marking-vs-bisync churn), ARCHITECTURE (registry + tripwire recorded), PUBLIC-SECTOR (status past #64), licensing-faq (check-in records disclosed; terms-of-use entry). cairn-site #33 tracks publishing EULA/LICENSE on swisscairn.ch pre-launch. EULA changelog line owed to the next feature release.

2026-09-07 — v0.6.137: leak tracing, where a leaked copy meets its download (#423)

Downloads of PDF/Office/PNG/JPEG can now carry an invisible CAIRNMARK1 token — random pseudonymous id + HMAC signature, never a name — embedded format-natively (zip EOCD comment, PDF trailing comment, PNG tEXt, JPEG COM) by a segmented composite reader that keeps streams seekable in O(1) memory and serves the original untouched when a container does not validate. The id→download ledger is monthly JSONL beside the audit log, same retention knob; only authenticated reads are marked. New Trace tab (visible only while marking is on or historic marks remain): drop a leaked copy, get the one download it came from, with a Query-prefill link for the full candidate list and an explicit evidence-not-verdict caution. Tracing audits itself as mark-trace (new marks verb group). Off by default behind an amber legal line; the handbook gained a leak-tracing page with a per-country legality table snapshotted 2026-09-07 and a model user notice. Decisions: no per-download audit verb (the ledger is that record), no content hash in v1 (double-read vs seek), Stat not wrapped (sync-size caveat documented instead).

v0.6.136 — 2026-09-07 — audit Action filter: searchable group-tree picker (#442)

  • Nikola's follow-up on seeing the 52-verb #433 dropdown live: mockup-first (full picker in three states — closed button chips, open tree, search), flyout-cascade option considered and rejected (navigation pattern, hostile on touch, collides with the #389 mobile pass; Carbon-style tree chosen).
  • Backend (TDD, compile-red witnessed): audit.Filter.Actions OR-set beside the untouched single Action; query endpoint accepts comma-separated actions= (old action= kept). Groups expand client-side, so the server stays group-agnostic and the #433 registry + scanner remain the single vocabulary source.
  • Frontend: chips on the closed button ("Any action" / "Trash · all" / "{n} actions"), popover opens at nine collapsed group rows instead of fifty-two, tri-state group checkboxes (a group tick = "all trash-related" — Nikola's ask), type-to-filter with highlight + auto-expand, Esc clears search first / closes second, click-outside closes, footer count + Clear, Reset clears the selection. 5 i18n keys ×4.
  • First one-shot in the batch to go green on run one. Dogfoods on v0.6.136 (build_info verified live).

v0.6.135 — 2026-09-06 — audit verb registry: one source of truth, a self-growing dropdown (#433)

  • Deep-researched first (Nikola's ask): GitLab keeps per-event YAML definitions with a schema + CLI and derives docs and the UI filter from them, but enforces only via runtime warning; Keycloak serves its EventType enum through serverinfo so the console filter can't drift, but the closed enum blocks extension; CloudTrail gave up on a complete dropdown at thousands of names (exact-match text entry); Nextcloud and Gitea/Forgejo have no audit filter UI at all. Cairn's design marries GitLab's registry to Keycloak's serve-from-server with build-time enforcement neither has, at ~2% of the machinery.
  • audit.Verbs (52 verbs, 9 groups) is the canonical, append-only vocabulary; verbs_test.go scans the repo and fails the build when an emitted verb — literal Action: fields plus the known dynamic families — is unregistered, or when an Action is built from an unexempted non-literal. The scanner caught real drift twice during its own build: the peering runtime family (peer-auth/send/deliver…), then auto-purge and key-drain wired in main.go — including disproving the research-phase theory that auto-purge was never emitted.
  • Info() serves the registry through the existing /api/v1/admin/audit/info; the query page builds grouped optgroups from it (9 i18n group labels ×4). The hardcoded seven-option list — which had missed nine features' worth of verbs — is gone; the dropdown is version-correct by construction.
  • Lesson for the log: three post-condition miscounts in one day (fillAuditVerbs, AUD_GRP_KEYS, licremdlg) — when asserting substring counts, count per emitted occurrence including repeats within one line, not per concept.

v0.6.134 — 2026-09-06 — Remove licence: clean downgrade to the free tier (#438)

  • Mockup-first (full Licensing page redone 1:1 after Nikola's screenshot feedback, then approved): the Licence facts card gains a footer — audit note left, quiet Remove licence… right, rendered only while a key is installed. Confirm dialog with a live-computed seat line: fits-the-free-tier, or amber over-50 in the #437 dropcard's calm wording. No typing ritual — the key stays valid in the customer account and pastes back any time.
  • Manager.RemoveKey (TDD, compile-red witnessed) clears key/label and every licence flag including a pending revoked watch, persists, and keeps seat history and the drop/restore trail; keyless means no phone-home ever, and no card or warning remains. DELETE /api/v1/license (admin-gated, 409 when nothing installed), audited as license-remove. Config/CAIRN_LICENSE_KEY keys re-install at boot — FAQ documents removing them from config to make the downgrade stick.
  • Docs: CHANGELOG v0.6.134, licensing-faq downgrade question, ARCHITECTURE admin-surface row, audit verb inventory. Dogfoods bumped and live (build_info v0.6.134; enc licence untouched, installed 1 / licensed 1).

cairn-license-server #40 — 2026-09-06 — check-ins observable at last (server half of the licence-drop incident)

  • Every /v1/checkin outcome now logs to the journal: checkin ok (id/org/seats/expires/keyReturned), checkin refused (id/reason/refusal count), bad requests. The August incident was unreconstructable because the handler logged nothing.
  • Refusals stamp the licence record — lastRefusedAt / lastRefusedReason / refusalCount in the SQLite data JSON — persisted, never cleared by a later success (a trail, not state).
  • An empty or unreachable store answers 503, not 403-unknown — the mid-redeploy position that self-revoked cairn-enc's healthy licence can no longer produce a refusal; clients retry on the grace path. With real licences loaded, unknown ids stay honest 403s (pinned by test).
  • TDD on the runner (assertion-red for 503 + logging, compile-red for the history fields), PR #41 merged, deployed via deploy.yml, live-verified: a probe check-in produced 403 reason=unknown and checkin refused id=lic-verify-40 reason=unknown refusals=0 in the journal.
  • Ops documentation: cairn-license-server wiki page Check-in Logging and Refusal History (log lines, the SQL to read the history, 503 semantics, incident playbook). Defence in depth with Cairn #437: the server tries never to give the wrong answer, the client survives it if one slips through.

v0.6.132-133 — 2026-09-06 — slimmer check-in; revoked is a watch, not a verdict (#428, #437)

  • v0.6.132: the monthly check-in payload is {licenseId} alone — seat usage never leaves the instance (decided on #428).
  • v0.6.133 (#437 — the August licence-drop incident, closed): a 403 revoked/unknown used to set a terminal revoked flag that silenced check-ins forever (one transient refusal during the licence-server redeploy = permanent free-tier downgrade, healed only by re-pasting the key). Now it starts a 14-day revoked watch: the licence stops counting immediately, daily check-ins continue, the first 200 restores it; past the watch the instance goes quiet for good. Legacy revoked state re-enters the watch on load. TDD: the red test replayed the incident and watched the retry never happen; TestForbiddenRevokedStillStops (which asserted the bug) became TestForbiddenRevokedEntersWatch.
  • Licence state now persists a capped drop/restore transition history with reasons (refused/grace/expired) — the forensic trail the incident lacked. The admin bell shows each transition once (click lands on Licensing); the licence page explains inactive keys with the approved state-driven dropcard + dimmed seat meter naming the suspended cap; 20 i18n keys ×4 locales; status API adds inactiveReason/inactiveSince/retryUntil/licSeats.
  • /metrics: cairn_license_installed/licensed/seat_cap (absent when unwired); homelab-config gained CairnLicenseDropped (installed==1 and licensed==0, 5m, critical). Dogfoods bumped and verified live: installed 1 / licensed 1 / cap 60.
  • Follow-ups: cairn-license-server #40 (log every check-in, persist refusal history, 503-not-403 when storage is unavailable), #438 Remove-licence button (mockup first).

Status

As of 2026-07-21: v0.2 complete + fully verified; v0.3 well underway. OIDC (Keycloak e2e), app passwords (API + UI), share links, TUS (go-tus resume-from-offset), and LDAP (live-bind against OpenLDAP uid and Samba AD sAMAccountName/LDAPS) all proven against live deployments. v0.3 so far: quota reporting (GET /api/v1/quota) + WebDAV quota props (RFC 4331) shipped in v0.2.1 (dogfood .247 rolled to it, Finder shows free space); admin API (auth.admins, merged); S3 driver verified as a live dogfood against Garage (cairn-s3 at .250). Remaining v0.3: age-encrypted storage (§4.3) — the last planned item; wants a key-custody + ranged-reads design pass before implementation. Optional follow-ups still open: quota enforcement, a real Uppy/tus-js browser upload, LDAP group resolution.

As of 2026-07-20 (evening): OIDC decision resolved and merged. External expert review chose the library route; PR #7 merged oidc-lib (through oidc-common) into main and both prototype branches were deleted — the hand-rolled verifier survives in git history, and OIDC-COMPARISON.md on main is the decision record. Main now has: OIDC Code+PKCE via go-oidc/v3+x/oauth2 (auth.mode: "oidc"), memory sessions + /auth/* endpoints, app passwords (atomic-JSON store, prefix-gated Basic provider, /api/v1/app-passwords), and the issue-#5 OOM limiter refactored to protect all argon2 verifiers. Dependency count: 4 direct-ish (x/crypto, go-oidc/v3, x/oauth2, + transitive go-jose/v4/x/sys); Go floor now 1.25 (go-oidc requirement). Also: Helm chart at deploy/chart (CI-validated), homelab validate.yml replaced with kubeconform, and the Forgejo→Argo webhook fixed (had never worked; two stacked faults — see homelab docs).

As of 2026-07-20 (morning): Issue #5 root-caused and fixed, v0.0.2 live on k3s, client interop (#2) complete. The Finder-mount pod crashes were OOM kills: each argon2id verification allocates its full 64 MiB parameter and Local.Authenticate ran them unbounded — Finder's parallel mount connections (identical credentials, cold cache) × 64 MiB blew the 256Mi limit. Fix (PR #6): inflight dedupe of identical concurrent verifications + a semaphore capping concurrent argon2 runs at 2 (~128 MiB worst case). Hardening: IdleTimeout 120s, outermost slog panic-recovery middleware, first internal/dav test batch. Interop matrix: Finder ✔, rclone ✔, Cyberduck ✔ — pod 0 restarts, ~132Mi peak; details on issue #2.

As of 2026-07-19: v0.0 scaffold ✔ · argon2id auth (#1) ✔ · observability + graceful shutdown (#4) ✔ · S3 driver ✔ (hand-rolled SigV4 + minimal client, conformance-tested against a live SeaweedFS gateway) · share links ✔ (file-backed store, public /s/ surface with passwords/expiry/file-drop) · share links web UI ✔ (feature-detected). Full end-to-end smoke tests after every feature.

Key decisions (with reasoning)

Decision Why
Go over Rust Single static binary, trivial arm64/amd64 cross-compile, go:embed for the UI, larger contributor pool, faster iteration for an MVP.
Hand-rolled WebDAV, not x/net/webdav That package's FileSystem contract assumes os.File semantics that map poorly to object stores; programming against our own storage.Driver keeps one abstraction. Also keeps the core dependency-minimal.
No database file index The storage backend is the source of truth (anti-oc_filecache stance). Out-of-tree state exists only for share links and app passwords — state that genuinely can't live in the file tree.
argon2id for local auth PHC string format, RFC 9106 second recommended parameters; cairnd hash-password reads stdin only; dummy-hash verification for unknown users closes the enumeration timing oracle; parameter validation prevents panic/memory-DoS from hostile hashes.
Verified-credentials cache in local/LDAP auth HTTP Basic resends credentials per request → every request paid full argon2 cost (~800ms) / an LDAP bind. SHA-256 fingerprint cached only after the credential is accepted; failure timings unchanged. Once per session instead of per request.
Bounded + deduped argon2 verification (issue #5) argon2id's 64 MiB-per-verification is a memory-amplification vector under parallel Basic-auth storms. Inflight map dedupes identical concurrent verifications to one argon2 run; a process-wide semaphore (2) caps distinct runs at ~128 MiB. Shared by local auth and app passwords via verifyLimiter.
OIDC via go-oidc + x/oauth2, not hand-rolled (decided 2026-07-20, expert-reviewed) Both variants were fully built and CI-tested against an identical fake-IdP attack suite before deciding. Library route won: audited/standard implementations, token verification is where hand-rolled auth historically fails, and the trust-surface argument was already weakened by the accepted go-ldap/ldap dependency for LDAP. OIDC-COMPARISON.md = decision record. Real-IdP e2e passed against Keycloak 2026-07-20.
App passwords bridge WebDAV in OIDC mode Sync clients can't do browser redirect flows. Per-device cairn--prefixed secrets, shown once, argon2id-hashed in an atomic-JSON store; prefix gating keeps non-app-password Basic attempts from burning argon2 in the wrong provider. Nextcloud/Vaultwarden pattern. LDAP mode doesn't need them (directory creds are Basic-compatible — verified 2026-07-21, OpenLDAP + Samba AD).
Hand-rolled SigV4 + minimal S3 client, no SDK ~120 lines of well-specified HMAC chaining (verified against AWS's published test vector) vs an entire SDK dependency tree. Path-style URLs for Garage/MinIO/SeaweedFS. Verified as a live dogfood against Garage 2026-07-21 (interop-s3.yml).
Share store: atomic-JSON file behind a Store interface, not SQLite (for now) modernc.org/sqlite is a transpiled-C codebase + libc emulation — the largest possible addition to the trust surface — for a write volume of "humans clicking buttons". Interface means SQLite can slot in later. Same pattern reused for app passwords.
Optional capabilities via interface + type assertion (not fatter Driver) Features not every backend can serve (presigned URLs, now Usager quota reporting) are separate one-method interfaces the caller feature-detects, rather than bloating the core storage.Driver contract with methods half the drivers would stub. POSIX implements Usager via statfs; S3 doesn't, and GET /api/v1/quota returns 501 there.
Admin identity = username allow-list (auth.admins), not a role system (yet) Cheapest thing that works across local/OIDC/LDAP (all yield a User.Username). Enables the minimal operator surface (/api/v1/admin/*) without per-file ACLs, which §5.4 deliberately defers.
Fake LOCK/UNLOCK Finder and MS Office refuse to write without class-2 lock support. Tokens are granted and ignored. Revisit only if concurrent-edit corruption is reported.
Depth: infinity PROPFIND rejected Unbounded tree walks are a DoS vector. Clients all use Depth 0/1 in practice.
Releases via ko from the runner, distroless base (not scratch) The host-mode runner has no Docker daemon; ko builds Go images daemonlessly and pushes a proper multi-arch manifest. distroless/static over scratch: adds CA roots + tzdata — needed the moment OIDC talks to an HTTPS IdP.
Fair-code license (n8n SUL model), not OSI open source Free ≤50 users, commercial above, no hosted resale. Never call it "open source" in marketing. Identity integrations (OIDC/LDAP) are Community-tier — no SSO tax; the 50-user line is the commercial fence, not capability gating.
License validation must be offline-capable Ed25519-signed license files verified locally. Sovereignty/air-gapped customers are the target market. Licensing outage must never lock data access.
age-encrypted storage for untrusted backends (planned, §4.3) External review proposed at-rest encryption; adopted as a storage.Driver decorator using filippo.io/age (library, not a service tier). Headline use: put a third-party S3 bucket under Cairn without trusting it. Opt-in per backend, honest threat model. Design task before implementation: key custody + ranged reads over encrypted objects.
Working name "Cairn" Alpine stone marker. Rename trivial while private; binary is cairnd, port 7420.

Runner / CI lessons (IMPORTANT — applies to all repos on this Forgejo)

The k3s Forgejo runner executes jobs in HOST mode — directly inside the Alpine act_runner pod. There is no Docker daemon and no job containers. Consequences for every workflow:

  • container: in a job → fails with "Cannot connect to the Docker daemon"
  • Node-based actions (actions/checkout, actions/setup-go, ...) → fail with "Cannot find: node in PATH"
  • The pod persists between runs → every step must be idempotent (tool installs skip-if-present; checkout must handle a dirty workspace: git checkout -f + git clean -fdx)
  • Checkout pattern that works: plain git init/fetch/checkout of $GITHUB_SHA using http://x-access-token:${GITHUB_TOKEN}@192.168.10.245/...
  • Official Go tarballs from go.dev are statically linked → run fine on Alpine/musl; install with runtime arch detection (uname -m), since jobs can land on amd64 or arm64
  • kubectl IS available to job steps but API calls authenticate as the unprivileged forgejo-runner:default ServiceAccount → Forbidden. Offline validation (kubeconform) is the right CI tool, not kubectl.
  • GITHUB_TOKEN cannot push to the package registry (401 reqPackageAccess). Image-publishing workflows need a dedicated REGISTRY_TOKEN actions secret (write:package only). It CAN push code — but workflow-token pushes don't re-trigger workflows (loop prevention).
  • Job-log API endpoints 404 via gitea-mcp on this Forgejo version — the runner pod's own logs are the reliable source: kubectl logs -n forgejo-runner deploy/forgejo-runner.
  • rerun_run via API also 404s — re-trigger tag workflows by deleting and re-creating the tag; dispatch_workflow works.
  • The runner doubles as a LAN test host: interop-rclone.yml (rclone WebDAV), interop-tus.yml (go-tus resumable), interop-ldap.yml (OpenLDAP live-bind), interop-ad.yml (Samba AD sAMAccountName/LDAPS live-bind), and interop-s3.yml (S3 driver over WebDAV against live Garage) — all manual-dispatch — run against live deployments from inside the network. Each reuses a CAIRN_* actions secret.
  • CI triggers are push:[main] + pull_request — pushes to a feature branch do NOT run CI; open a PR to get a run. Use a feat/* branch → PR → squash-merge for reviewable changes instead of committing straight to main.
  • PR CI runs check out the branch head, not the merge result — a branch that predates a file main's ci.yml needs fails spuriously; update-branch-from-base before trusting the run.

Coordination gotcha: parallel sessions racing the same task (2026-07-20)

Two Claude sessions worked the Keycloak deployment concurrently and both pushed manifests for the same Deployment/Service/PVC — Argo applied both every sync, last-write-wins, until consolidated into one file. Rule: one session per repo/task area at a time, or tell each session the other is active.

Tooling gotcha: Claude Desktop MCP "server hang" that isn't (2026-07-19)

gitea-mcp calls timed out after 4 minutes with a "server unresponsive" symptom. The log showed the server was healthy — the actual line was "Client transport closed (renderer released port)": Claude Desktop's renderer dropped the stdio connection. Fix: restart Claude Desktop. (Distinct from the two real gitea-mcp bugs — sha:"" for new files and the -d stdout corruption.)

Content round-trip gotcha (2026-07-21)

Editing a large existing repo file or wiki page by hand-transcribing its base64 back into a sandbox is unreliable — a single mistyped character (e.g. a look-alike non-ASCII glyph) silently corrupts the decode. Reliable pattern: work from plaintext you actually read (the file's rendered content), apply edits to that, and push the full plaintext via create_or_update_file. Never round-trip 20+ KB of base64 by retyping it. (Corollary for files with ASCII-art diagrams: keep the diagram region untouched where possible; box-drawing glyphs are the easiest thing to corrupt.)

Image gotcha: Bitnami Docker Hub deprecation (2026-07-21)

docker.io/bitnami/* images were emptied in Bitnami's 2025 deprecation — bitnami/openldap:latest now 404s. The images were archived to the bitnamilegacy/ org (same multi-arch, incl arm64, but frozen/unsupported). Fine for throwaway test infra; don't build production on it. Also, for bitnami openldap specifically: the deprecated LDAP_USER_DC + LDAP_GROUP env collide on ou=users and crash tree-seeding with exit 68 (LDAP entryAlreadyExists) — use minimal env (users default to ou=users).

Known limitations (tracked as issues)

  • Real-client interop (#2): Finder ✔, rclone ✔, Cyberduck ✔ — all 2026-07-20 against v0.0.2. Windows Explorer untested (nice-to-have).
  • OIDC: real-IdP e2e PASSED (Keycloak, 2026-07-20) — browser login + app-password WebDAV both verified; the app-password management UI has shipped.
  • LDAP: live-bind fully verified (2026-07-21) — search-then-bind against live OpenLDAP (uid, interop-ldap.yml) and live Samba Active Directory (sAMAccountName over LDAPS, interop-ad.yml), each PROPFIND 207 / PUT 201 / GET byte-match / DELETE 204; wrong-password + unknown-user 401. Group resolution (memberOf) not yet exercised (not needed for the bind).
  • TUS: real-client verified (2026-07-21)eventials/go-tus resume-from-offset against the live dogfood (v0.2.0). A real Uppy/tus-js browser upload is still a nice-to-have.
  • Quota: reporting + WebDAV props shipped (v0.2.1, 2026-07-21) — POSIX statfs; S3 returns 501 (no bucket-wide capacity). WebDAV RFC 4331 props serve on collections (Finder free-space). Quota enforcement is not yet done.
  • S3 driver: live-dogfood verified against Garage (2026-07-21, interop-s3.yml) — the SeaweedFS conformance suite is env-gated and now backed by a live-deployment proof against the actual target (Garage).
  • Authorization is coarse: any authenticated user sees the whole tree; admins (auth.admins) can list/revoke any share via /api/v1/admin/*. Per-user home directories and per-file ACLs are designed (ARCHITECTURE.md §5.4) but not implemented.
  • Go toolchain floor is now 1.25 (go-oidc); CI/release workflows pin go1.22.5 and rely on GOTOOLCHAIN auto-download — bump when convenient.

Test infrastructure (homelab)

  • Keycloak (test IdP): http://192.168.10.248, realm cordyceps, public client cairn (PKCE S256), redirect URIs for .247/.249. Manifests: homelab-config k3s/keycloak/. Test user nikola-test.
  • Cairn OIDC test instance: http://192.168.10.249, v0.0.3, auth.mode: oidc. Manifests: k3s/cairn/test-instance.yaml.
  • Cairn S3 dogfood (persistent, second dogfood): http://192.168.10.250, v0.2.1, storage.driver: s3 against Garage bucket cairn-dogfood (192.168.10.32:3900, region garage), auth.mode: local (user nikola). Config in the cairn-s3-config secret (endpoint/bucket/region + Garage key + reused nikola hash; not in git). Manifests: k3s/cairn/s3-dogfood.yaml. Re-verify with interop-s3.yml.
  • OpenLDAP / Samba AD test directories: both torn down after use (2026-07-21). Manifests were k3s/openldap/ and k3s/samba-ad/ (throwaway); the interop workflows (interop-ldap.yml, interop-ad.yml) remain in the Cairn repo for re-runs — recreate the directory + the .250 test instance and the CAIRN_*_TEST_PASSWORD secret to re-verify.

Development notes

  • make buildbin/cairnd (~5.4 MB static). make release cross-compiles amd64+arm64.
  • Releases: push a v* tag → release.yml builds and pushes the multi-arch image to 192.168.10.245/cordy/cairn:<tag> via ko. Latest: v0.2.1 (dogfood .247 runs it; cairn-s3 at .250 runs it too).
  • Dependency changes: dispatch go-tidy.yml on the target ref — regenerates go.mod/go.sum on the runner and pushes back.
  • Interop retest: dispatch interop-rclone.yml, interop-tus.yml, interop-ldap.yml, interop-ad.yml, or interop-s3.yml from the Actions tab — each reuses a CAIRN_* secret.
  • Feature branches: CI only runs on push:[main] + pull_request, so use feat/* → PR → squash-merge (see Runner/CI lessons). Minor/patch release tags are cut autonomously (owner-approved 2026-07-21).
  • web/static/ is the UI source of truth; make web copies into cmd/cairnd/webdist/ for go:embed.
  • Module path is github.com/nikolasomborski/cairn.
  • Share links are opt-in (shares.enabled, default off); store files must live on persistent storage, written 0600.
  • zsh gotcha (interactive): # does not start a comment by default — pasting commands with trailing # comments passes them as arguments.

Roadmap snapshot

v0.1 usable daily ✔ complete 2026-07-20 → v0.2 product core (OIDC ✔ + app passwords ✔, real-IdP e2e ✔, app-password UI ✔, LDAP provider ✔ (live-bind verified — OpenLDAP uid + Samba AD sAMAccountName/LDAPS), share links ✔ + UI ✔, TUS ✔ (go-tus resume-verified) — v0.2 complete + fully verified) → v0.3 sovereignty (S3 driver ✔ + live Garage dogfood ✔ 2026-07-21, quota reporting ✔ + WebDAV quota props ✔ (v0.2.1), admin API ✔, quota enforcement pending, age-encrypted storage §4.3 = last remaining v0.3 item) → v0.4 commercial (license files, license server, landing page, GitHub mirror).