Commit Graph

81 Commits

Author SHA1 Message Date
CarterPerez-dev 80bd617378 style(canary): format config_test.go with golines (max-len 80)
Vertically wrap the long table-driven case literals and one Fatalf call to
satisfy the project's golines linter (max-len 80, reformat-tags), which the CI
Go lint job flagged. Formatting only; no test behavior change.
2026-07-19 03:18:17 -04:00
CarterPerez-dev 97da7fb3a0 feat(canary): add configurable trusted-proxy CIDRs for X-Forwarded-For
RealIP only reads XFF when the immediate peer sits in TRUSTED_PROXY_CIDRS,
so an untrusted client can no longer spoof its source IP while a real client
behind a known reverse proxy still resolves correctly. Parses the
comma-separated list via a knadh/koanf ProviderWithValue callback (blank or
whitespace-only input leaves the built-in default intact), wires the var
through both compose files and .env.example, and adds MYSQL_FAKE_* and
TURNSTILE_SECRET env aliases. Covered by config_test.go.
2026-07-19 02:45:10 -04:00
dependabot[bot] e196026071
chore(deps): bump golang.org/x/crypto
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.51.0 to 0.52.0.
- [Commits](https://github.com/golang/crypto/compare/v0.51.0...v0.52.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.52.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 21:32:07 +00:00
dependabot[bot] 8af023afab
chore(deps): bump golang.org/x/net
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.53.0 to 0.55.0.
- [Commits](https://github.com/golang/net/compare/v0.53.0...v0.55.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.55.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-03 17:55:50 +00:00
dependabot[bot] f742a39b65
chore(deps): bump github.com/go-chi/chi/v5
Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from 5.2.3 to 5.2.4.
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.2.3...v5.2.4)

---
updated-dependencies:
- dependency-name: github.com/go-chi/chi/v5
  dependency-version: 5.2.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 14:09:37 +00:00
Carter Perez 163d126655
Merge pull request #256 from CarterPerez-dev/chore/add-linting-to-yml-and-pre-commit
Chore/add linting to yml and pre commit
2026-05-23 06:43:38 -04:00
CarterPerez-dev 0006ddad3d cracked 2026-05-23 05:01:01 -04:00
dependabot[bot] 3250ddbbe3
chore(deps): bump github.com/redis/go-redis/v9
Bumps [github.com/redis/go-redis/v9](https://github.com/redis/go-redis) from 9.7.0 to 9.7.3.
- [Release notes](https://github.com/redis/go-redis/releases)
- [Changelog](https://github.com/redis/go-redis/blob/master/RELEASE-NOTES.md)
- [Commits](https://github.com/redis/go-redis/compare/v9.7.0...v9.7.3)

---
updated-dependencies:
- dependency-name: github.com/redis/go-redis/v9
  dependency-version: 9.7.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 14:18:33 +00:00
dependabot[bot] 7623b8f557
chore(deps): bump github.com/go-viper/mapstructure/v2
Bumps [github.com/go-viper/mapstructure/v2](https://github.com/go-viper/mapstructure) from 2.2.1 to 2.4.0.
- [Release notes](https://github.com/go-viper/mapstructure/releases)
- [Changelog](https://github.com/go-viper/mapstructure/blob/main/CHANGELOG.md)
- [Commits](https://github.com/go-viper/mapstructure/compare/v2.2.1...v2.4.0)

---
updated-dependencies:
- dependency-name: github.com/go-viper/mapstructure/v2
  dependency-version: 2.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 14:03:28 +00:00
CarterPerez-dev de6ac71954 chore(canary): drop stray /healthcheck binary + ignore cmd build artifacts
go build ./cmd/healthcheck (run for verification, not via the bin/ recipe)
drops the binary in the working dir. Untracked previously; landed in the
last commit by accident. Gitignore now lists each cmd binary explicitly so
the same mistake can't recur for canary, healthcheck, or the two pdf/docx
template builders.
2026-05-18 00:03:20 -04:00
CarterPerez-dev 10321a9a61 fix(canary): prod compose can actually start
Three things that all blocked tunnel-start:

1. justfile: dropped set dotenv-load / set export — they pulled
   .env.development into every recipe shell, where empty values
   beat docker compose --env-file .env per shell-env precedence
   (POSTGRES_PASSWORD was the visible casualty). Dev recipes now
   pass --env-file .env.development explicitly so they're not
   affected by the change.

2. canary image is distroless/static, so the wget-based healthcheck
   had no binary to run and every check failed → nginx/tunnel never
   started. Added cmd/healthcheck (tiny Go HTTP probe of /healthz),
   built alongside canary, swapped compose healthcheck to /healthcheck.

3. Added tunnel-build and tunnel-rebuild recipes so the next person
   doesn't have to remember which compose files the tunnel overlay
   needs.
2026-05-18 00:02:51 -04:00
CarterPerez-dev 9655120ebe chore(canary): phase B audit fixes + frontend asset rename + gitignore
Audit Phase B (backend): F5 trusted-proxy gate wiring, F7 ErrValidation
sentinel, F8 dedup unique-IP semantic via SCard, F9 notify worker pool +
graceful Shutdown, F12 token ID collision retry.

Frontend: public/asset/ → public/assets/ rename + index.html paths.

Housekeeping: .npmrc strict-dep-builds=false (pnpm fix), .gitignore now
excludes all .env.* except .env.example.
2026-05-17 23:34:42 -04:00
CarterPerez-dev d96de872b0 chore(canary): halve webbug pixel.jpg dimensions (736→368) 2026-05-17 22:58:53 -04:00
CarterPerez-dev 159c942551 feat(canary): webbug serves a real (visible) image instead of 1x1 GIF
Operator request: give the webbug some personality. Trigger response is now
a small JPEG embedded into the binary via //go:embed, served from
backend/internal/token/generators/webbug/asset/pixel.jpg. Default is a
545-byte 32x32 yellow placeholder I generated with imagemagick — replace
the file with any image (same path, same filename, doesn't matter what
size or content) and the next backend rebuild picks it up. No other code
needs to change.

Scope: ONLY webbug. The docx, pdf, and envfile generators keep their own
Trigger functions that still call pixel.Clone() for the canonical
invisible 1x1 GIF — those types embed the trigger URL inside their
respective artifact bytes (a Word footer, a PDF /AA action, a fake .env
INTERNAL_METRICS_ENDPOINT line) and need the response to stay invisible
so the attacker doesn't notice they pinged us. Stealth preserved where
it matters.

The pixel package itself is untouched (still has the 43-byte GIF
constant); only webbug stops importing it.

Tradeoff baked in: a webbug whose response is a visible image is
*technically* less stealthy than the canonical 1x1 — the attacker can
see "huh there's a small image here." But the visible mechanism is real
too (think Mailchimp / SendGrid email-open tracking, which use sized
"social proof" images, not just 1x1s), so this is a legitimate variant.
For an operator who wants strict 1x1-invisible behavior in prod, the
revert is a 3-line change in webbug/generator.go (swap embed for
pixel.Clone() again).

Tests updated: webbug/generator_test.go no longer asserts gifByteLength
or pixel.Clone() equality; it now asserts the body starts with the JPEG
SOI marker (0xff 0xd8) + ContentType is image/jpeg. The
"independent-copy-per-call" invariant test still passes (bytes.Clone in
the Trigger ensures each Body slice is a fresh allocation).

Cache headers unchanged (no-store / no-cache / must-revalidate) so every
fetch hits the trigger handler.
2026-05-17 22:55:47 -04:00
CarterPerez-dev 4b54c1b825 chore(canary): align e2e + handler tests with mysqlEnabled signature
Upstream 8d2599c0 added a mysqlEnabled parameter to token.NewHandler and
took a long line over the limit in handler_test. Pass the parameter from
the e2e stack (true: integration test exercises mysql token creation) and
reflow the long handler_test return so golines is happy.
2026-05-17 22:55:28 -04:00
CarterPerez-dev 8d2599c01a feat(canary): mark MySQL type as disabled when mysql.enabled=false
Operator request: when MYSQL_FAKE_ENABLED=false (the dev default + the only
viable setting behind Cloudflare Tunnel since CF Tunnel doesn't carry raw
TCP), surface that in the UI so users can see the species but understand
they can't deploy it on this instance. Avoids the silent-broken case where
someone creates a mysql token, gets a connection_string artifact, points
their MySQL client at it, and just times out with no clue why.

Backend (3-line change):
- TypeDescriptor gains `Enabled bool` + optional `DisabledReason string`
- TypeDescriptors() now takes `mysqlEnabled bool`; mysql is the one whose
  Enabled tracks the flag, all others are always true (they don't need
  any deployment-level infra beyond HTTP)
- Handler stores mysqlEnabled, passes to descriptors call; main.go wires
  cfg.MySQL.Enabled in. Handler tests updated to pass false explicitly.

Frontend (matching schema + visual treatment):
- typeDescriptorSchema picks up `enabled` (default true for backward compat)
  and `disabled_reason` (optional string)
- TypeCard reads descriptor.enabled, sets `data-disabled` on the label,
  disables the radio input, replaces the blurb with the disabled_reason
  when present (so the user sees WHY rather than guessing), and renders
  a small "disabled" pill in the top-right corner of the card
- SCSS: dashed border, muted palette, opacity 0.55, glyph faded, cursor
  not-allowed, hover effect suppressed — reads as "shelf is here but the
  specimen is checked out" rather than a broken button

In the operator UI: mysql card renders ghosted with the explanatory text
"Requires direct TCP exposure (port 3306). Not reachable via Cloudflare
Tunnel — only enable on a VPS with raw TCP access." Selecting it is
mechanically impossible (input is disabled), no form-level guard needed.

Flip MYSQL_FAKE_ENABLED=true in env when you stand up a VPS later and the
card re-enables with no other code changes.

Backend builds clean + token-package tests pass.
Frontend gates green: typecheck + biome + lint:scss + build (431ms).
2026-05-17 22:45:53 -04:00
CarterPerez-dev 22f1daf431 test(canary): e2e suite — create→trigger→notify→manage across all 7 types
Audit F23: integration tests covered repositories and individual handlers
but no test exercised the full chain. The PDF padding bug (F2) had passing
unit tests for years because the substring-only assertion masked the
embedded underscore padding.

New //go:build integration test in internal/server spins up Postgres via
testcontainers + miniredis, walks each of webbug, slowredirect, docx, pdf,
kubeconfig, envfile, mysql: POSTs /api/tokens, extracts the trigger URL
from the artifact bytes the way a victim would (regex for non-binary, ZIP
walk for docx, base64 decode for pdf), GETs the URL, then asserts the
manage view sees the event and the notifier fired. PDF subtest asserts the
byte after the embedded /c/<id> is not '_' — the structural check that
catches F2-style regressions.
2026-05-17 19:08:12 -04:00
CarterPerez-dev 2c033c58f2 fix(canary): webhook sender blocks SSRF to private/loopback/IMDS hosts
Audit F3: validateURL checked scheme/host/userinfo but never resolved the
host, so an operator-supplied webhook_url could point at the canary's own
Redis (redis:6379), Postgres, link-local IMDS (169.254.169.254), or any
RFC1918 host on the docker network or VPS subnet — a classic confused-deputy
SSRF triggered by self-triggering a token after creation.

validateURL now resolves the hostname (or parses a literal IP) and rejects
loopback, RFC1918, CGNAT, link-local, multicast, unspecified, IMDS, and
IPv6 unique-local. The default HTTP client also installs a DialContext that
re-checks the dialed IP for defense-in-depth against DNS rebinding. The
Config gains an AllowPrivateHosts flag (default false) that test code opts
into when targeting httptest.NewServer.
2026-05-17 19:08:01 -04:00
CarterPerez-dev 30ebda39d5 fix(canary): PDF trigger URL uses ?p= query pad; handler trims trailing _
Audit F2: the PDF placeholder was padded with underscores embedded directly
in the /URI path, so Acrobat fetched /c/<id>____________ and the chi route
extracted the padded id. Lookup returned ErrNotFound, the webbug fallback
served a 1x1 GIF, and event recording skipped because tok was nil. PDF
tokens silently never recorded events.

Part B (durable): pad inside a ?p= query string so the trigger path stays
canonical and chi extracts the real id. Part A (defense in depth): handler
TrimRight on _ so PDFs already in the field also resolve correctly. Added
two regression tests: byte-after-id assertion in the PDF artifact, and the
handler's trim path.
2026-05-17 19:07:50 -04:00
CarterPerez-dev 4382ba5e17 fix(canary): CSP allows challenges.cloudflare.com for Turnstile
Audit F10: script-src 'self' blocked the Turnstile bootstrap script
(https://challenges.cloudflare.com/turnstile/v0/api.js) and its widget
iframe. Added challenges.cloudflare.com to script-src, connect-src, and a
new frame-src directive so the widget can render under prod CSP.
2026-05-17 19:07:41 -04:00
CarterPerez-dev 5884349336 fix(canary): dev nginx routes /c/, /k/, /api/ to canary + manage_url defaults to base_url
Two tightly-coupled bugs visible after the PUBLIC_BASE_URL fix landed:

1. Trigger URLs now correctly show :22784 (the host-exposed nginx port),
   but opening one in the browser rendered the SPA's NotFound page instead
   of hitting the backend trigger handler.

   Root cause: dev.nginx only had `location /` → frontend_dev. No location
   block for `/c/`, `/k/`, or even `/api/`. Every request went to the SPA;
   only `/api/*` happened to work because vite's internal proxy caught it
   on its way through frontend_dev → canary. /c/* and /k/* (the trigger
   paths) just fell through to the SPA fallback.

   Fix: add `upstream canary { server canary:8080 }` to nginx.conf and
   three new location blocks in dev.nginx for /api/, /c/, /k/. Each one
   proxy_passes directly to the canary upstream with X-Real-IP +
   X-Forwarded-For + X-Forwarded-Proto set so middleware.RealIP() resolves
   the actual visitor IP rather than the docker bridge. Mirrors what
   spec §12.3 prescribes for prod nginx.

2. Manage URL in responses still showed :8080 even after PUBLIC_BASE_URL
   was set to :22784. Two separate config keys: canary.base_url
   (PUBLIC_BASE_URL → trigger_url) and canary.manage_url (CANARY_MANAGE_URL
   → manage_url). The latter has no env-name overlap with the spec's
   PUBLIC_BASE_URL, so it kept its default literal "http://localhost:8080".

   Fix: in config.Load(), after unmarshal, fall manage_url back to base_url
   when it's empty or still equals the bare default. Operators setting one
   env var (PUBLIC_BASE_URL) now get both URLs computed off it. Explicit
   CANARY_MANAGE_URL still wins if set — preserves the prod ability to
   serve manage at a different domain than triggers. Extracted the literal
   to a `defaultCanaryBaseURL` const so the default and the fallback
   sentinel reference the same string.

After this + the prior PUBLIC_BASE_URL alias, a fresh token from the form
should produce trigger_url + manage_url both pointing at
http://localhost:${NGINX_HOST_PORT}/... — clickable, reachable, and triggers
hitting /c/ now route to canary and record events.

Operator action: just dev-down && just dev-up to rebuild nginx + canary
containers with the new config.
2026-05-17 18:12:09 -04:00
CarterPerez-dev 2d395d0691 fix(canary): map PUBLIC_BASE_URL env var to canary.base_url
Spec §12.4, .env.example, .env.development, and dev.compose.yml all use the
name PUBLIC_BASE_URL for the externally-reachable URL stamped into trigger/
manage links. But the koanf env-key map in config.go only routed
CANARY_BASE_URL → canary.base_url. So setting PUBLIC_BASE_URL had no effect,
the default fallback "http://localhost:8080" silently won, and every issued
token came back with trigger_url/manage_url like
"http://localhost:8080/c/<id>" regardless of where canary was actually reachable.

Visible to the operator as: trigger/manage URLs in the SPECIMEN ISSUED dossier
pointing at :8080 (where canary doesn't even listen externally — host port is
nginx ${NGINX_HOST_PORT}, defaulting to randomised 22784).

Fix is one line: add PUBLIC_BASE_URL alongside CANARY_BASE_URL in the env-key
map. Both env names now route to canary.base_url. Backwards-compatible —
existing CANARY_BASE_URL deployments keep working.

Operator action: set `PUBLIC_BASE_URL=http://localhost:${NGINX_HOST_PORT}` in
.env.development (e.g. http://localhost:22784) and restart canary. URLs will
then reflect the actual reachable address.
2026-05-17 18:02:48 -04:00
CarterPerez-dev 32e9c0a30e fix(canary-phase13): direct unit coverage for firstSubdivisionName
Code-review audit flagged firstSubdivisionName as exercised only
transitively through extractLookup. Adds five direct unit tests
covering the documented branches (nil/empty slice, English-name
prefers, ISOCode fallback, skip-empty-then-find-populated,
all-empty returns ""). Fix-in-phase per project convention; no
backlog rot.
2026-05-17 05:30:13 -04:00
CarterPerez-dev 0e5ce63dc8 feat(canary): wire geoip into main with NopService fallback
openGeoIP opens cfg.GeoIP.Path and returns a (Lookuper, closer)
pair; on open failure (missing/unreadable mmdb) it returns
geoip.NopService() and a no-op closer so the rest of the wire-up
sees a consistent non-nil interface and the binary stays healthy
in environments without a MaxMind license. buildEventStack now
accepts a geoip.Lookuper and threads it into event.ServiceConfig.

Close is deferred in run() so the mmdb file handle releases on
graceful shutdown; a close-error is logged at warn (not fatal).
2026-05-17 05:24:29 -04:00
CarterPerez-dev b3fd75b680 feat(canary): Event.AttachGeoIP + event.Service geo enrichment
- entity: (*Event).AttachGeoIP(geoip.Lookup) sets Geo* nullable
  pointer fields with overwrite semantics (empty/zero collapses to
  nil so DB column stays NULL). Single-arg signature matches the
  call shape in design spec §9 (line 1437) and keeps the helper
  symmetric with the geoip.Lookup value type.
- service: ServiceConfig.GeoIP geoip.Lookuper plumbed through to
  the Service.geo field. Record now calls enrichGeo(evt) BEFORE
  s.repo.Insert so geo_country/region/city/asn/asn_org persist in
  the same row. enrichGeo is nil-safe on three axes (geo, evt,
  evt.SourceIP) — geo enrichment is best-effort, never an error
  path.
- service_test: fakeLookuper + newSvcWithGeo helper; five new
  tests (enriches-before-insert, no-geo-leaves-nil, empty-IP-
  skips-lookup, NopService-leaves-nil, best-effort-when-insert-
  fails). All pre-existing tests pass unchanged because GeoIP
  defaults to nil.
- entity_test: seven AttachGeoIP unit tests covering populate-all,
  empty Lookup, partial fields, zero/negative ASN guards, overwrite
  semantics, and ASN-pointer independence from the input value.
- integration_test: two real-Postgres tests (stub Lookuper writes
  populated geo_* columns; NopService leaves them NULL) verify
  the wiring round-trips through the Insert SQL.
2026-05-17 05:24:17 -04:00
CarterPerez-dev ac334b14f9 feat(canary): geoip wrapper package + config wiring
- internal/geoip: Service over oschwald/geoip2-golang v2 with City db
  enrichment (Country/Region/Subdivision/City) and a NopService
  factory for environments without the mmdb file. Nil-safe Lookup
  returns a zero geoip.Lookup on any failure path (nil receiver,
  nil reader, empty/malformed IP, reader error, !rec.HasData).
- internal/geoip: unexported cityReader interface allows whitebox
  fake-based unit testing without bundling a real .mmdb fixture;
  extractLookup is a pure function for synthetic-record coverage.
- config: GeoIPConfig{Path} koanf section; GEOLITE_PATH env binding
  matching compose.yml; default /data/GeoLite2-City.mmdb.
- deps: github.com/oschwald/geoip2-golang/v2 v2.1.0.

ASN/ASNOrg fields are reserved on geoip.Lookup but stay zero with a
City-only mmdb; populating them is a future-extension concern (ASN
db is not fetched by scripts/init.sh per design §12.5).
2026-05-17 05:23:53 -04:00
CarterPerez-dev 3cf198657b fix(canary-phase12): address audit findings before phase rollup
- Rename admin.Handler.Register → RegisterRoutes for parity with
  health.Handler.RegisterRoutes and token.Handler.RegisterAPIRoutes /
  RegisterManageRoutes / RegisterTriggerRoutes. Spec §6.3 line 558
  also illustrates the canonical wire-up as
  `adminH.RegisterRoutes(api)`.
- Drop the local strItoa helper in handler_test.go; use strconv.Itoa.
  The helper was a code-reviewer nit — equivalent stdlib already
  exists; fewer lines, less surface to drift.

Both findings are non-blocking but covered by the fix-in-phase rule
(no backlog rot). Phase 12 audits otherwise PASS on first dispatch:
- superpowers:code-reviewer: PASS
- general-purpose spec-adherence: PASS
2026-05-14 07:02:39 -04:00
CarterPerez-dev 9adb3d2eb8 feat(canary): wire /api/admin mount + integration tests
- cmd/canary/main.go: mountAdminRoutes helper keeps run() and
  mountRouter under funlen 100. Admin routes mounted INSIDE the /api
  subrouter so they inherit read-tier rate limit + CORS; gated by
  OperatorBearer(cfg.Operator.Token). If OPERATOR_TOKEN is unset, the
  entire mount is skipped (no Bearer middleware, no routes — admin
  endpoints simply do not exist), and a structured warn log records
  the reason. This is the production safety: misconfiguration leaves
  the endpoint nonexistent rather than reachable.
- internal/admin/integration_test.go: testcontainers-backed end-to-end
  tests exercising the full /api/admin/* stack:
    * 404 on missing Authorization (no WWW-Authenticate leak)
    * 404 on wrong token
    * 200 on /stats with seeded mixed tokens + events
    * 200 on /tokens?limit=2 with HasMore/NextOffset accuracy
    * 204 on disable + DB-level verification token.Enabled flipped
    * 404 on disable for a missing id
  All requests go through the OperatorBearer middleware to verify the
  wire-up matches the unit behavior.
2026-05-14 06:57:39 -04:00
CarterPerez-dev 9e82f70841 feat(canary): admin handler (stats + tokens list + disable) + repo methods
- internal/admin/handler.go: replaces unused template stats handler
  with the canonical Phase 12 endpoints from spec §8.1:
    GET  /stats                — tokens_count, events_count, by_type,
                                 by_alert_channel
    GET  /tokens               — offset-based pagination, default 50,
                                 cap 100; returns full token.Response
                                 list with trigger/manage URLs via
                                 injected URLBuilder; 400 BAD_PARAM on
                                 negative/non-int offset
    POST /tokens/{id}/disable  — 204 on success; 404 NOT_FOUND envelope
                                 when token.ErrNotFound; 500 envelope
                                 on other repo errors
  Handler takes TokenRepository + EventRepository + URLBuilder
  interfaces (test seam); wire-up supplies *token.Repository,
  *event.Repository, *token.Service (which already implements
  TriggerURL/ManageURL).
- internal/admin/dto.go: Stats, TokenListPage, TokenListResponse.
- token.Repository.CountByType / CountByAlertChannel: GROUP BY type
  and GROUP BY alert_channel; returned shapes typed as TypeCount /
  ChannelCount with json+db tags for direct JSON emission.
- event.Repository.CountAll: global event count.
- Handler tests cover happy path + 500 propagation for each repo
  dependency; pagination (default/limit-cap/offset paging); 400 on
  bad offset; 404 on disable miss; 405 on GET to disable endpoint
  (sanity check that POST-only routing is intentional).
2026-05-14 06:57:28 -04:00
CarterPerez-dev 426ed54594 feat(canary): OperatorBearer middleware + operator token config
- internal/middleware/operator_bearer.go: returns 404 on miss (NOT 401)
  per spec §11.5; subtle.ConstantTimeCompare for timing safety; rejects
  when configured token is empty (defense-in-depth even though wire-up
  skips mount in that case).
- Table-driven test covers: missing header, empty header, wrong scheme
  (Basic), case-sensitive scheme (lowercase bearer), no payload,
  no-space-after-Bearer, equal-length wrong token, different-length
  wrong token, correct token, empty-config rejects both empty and
  populated headers, extra-whitespace rejection. Also asserts no
  WWW-Authenticate header on 404 (endpoint-hiding intent).
- config.OperatorConfig + OPERATOR_TOKEN env mapping; default empty
  (mount is skipped when empty).
2026-05-14 06:57:10 -04:00
CarterPerez-dev ca67cf6313 fix(canary-phase11): use repo-supplied HasMore/NextCursor before rollup
Both audits flagged buildPage as a non-blocking but worth-cleaning nit:
it re-derived next_cursor from len(events) and the last event's ID
rather than using the HasMore + NextCursor flags that
event.Repository.ListByToken already computes via the LIMIT+1 peek
trick. Both calculations agreed today, but the duplication would
mask a divergence if the repo's pagination logic ever evolved
(e.g. switched to opaque token cursors).

Inline buildPage into gatherManageData and use list.HasMore +
list.NextCursor as the authoritative signals. Drops the buildPage
helper. Behavior identical; tests still pass.
2026-05-14 01:12:07 -04:00
CarterPerez-dev 884215288e feat(canary): wire manage routes + extract buildHTTPDeps + integration tests
- cmd/canary/main.go: tokenH.RegisterManageRoutes(api) mounts GET +
  DELETE /m/{manage_id} inside the /api subrouter (read-tier rate
  limit applies; no Turnstile, no create-tier limits — read-style
  endpoints by spec §8.1).
- Extract buildHTTPDeps to keep run() under the funlen ceiling (was
  102, now under 100). Pulls out registry build + tokenSvc + verifier
  + healthH + tokenH construction. Returns the four handles run()
  needs to wire the router and shutdown path.

Integration tests (internal/event/integration_test.go,
testcontainers-Postgres + miniredis):
- TestIntegration_ManagePageReturnsTokenAndEventsAndSilencedCount:
  POST creates a webbug, three triggers from the same CF-Connecting-IP
  fire (one notification, two deduped per the Phase 10 dedup gate);
  GET /api/m/{manage_id} returns token with TriggerCount=3,
  EventsTotal=3, EventsSilencedActive=2, three event records with
  exactly one NotifySent and two NotifyDeduped, no next page.
- TestIntegration_ManagePageReturns404OnUnknownManageID
- TestIntegration_ManageDeleteCascadesEvents: delete via DELETE
  /api/m/{manage_id}, verify CountByToken == 0 (FK ON DELETE CASCADE)
  and the token is gone (token.ErrNotFound).
2026-05-14 01:07:38 -04:00
CarterPerez-dev 593433fc03 feat(canary): manage page handlers + DTOs (GET + DELETE /m/{manage_id})
Adds the HTTP layer for spec §8.4. The UUID in the URL is the
capability — no other auth required, by design.

- token/dto.go: ManageTokenView (slimmer than Response — no manage_id,
  no manage_url, no metadata, since the user is already on the manage
  page and those would be redundant), ManagePage{NextCursor, HasMore},
  ManageResponse{Token, Events, EventsTotal, EventsSilencedActive, Page}.
  Token.ToManageView(triggerURL) builder. Events use the existing
  event.Response (already matches spec shape exactly).
- token/handler.go: EventQuery + DedupCounter interfaces declared near
  the existing EventRecorder + FingerprintRecorder; same dependency-
  injection style. NewHandler signature gains the two new args (5 → 6;
  test sites + main.go updated).
- GetManage(w, r): parses ?cursor= (int64, must be >= 0) and ?limit=
  (defaults 20, capped at 100). Returns 404 envelope for unknown
  manage_id (envelope, not bare http.NotFound, so frontend gets a
  parseable error code). 400 BAD_CURSOR for non-numeric or negative.
  Calls svc.GetByManageID + eventQuery.ListByToken + CountByToken +
  dedupCounter.CountActiveDedup; assembles the manage payload.
- DeleteManage(w, r): 204 on success, 404 on miss. Cascade-delete is
  via the existing tokens-events FK ON DELETE CASCADE.
- buildPage helper: NextCursor is the string of the last event's ID
  iff len(events) == limit (matches spec example "next_cursor": "41").

Tests cover happy path, 404 on unknown id, 400 on bad/negative cursor,
?limit= respected, ?limit=999 capped at 100, DELETE happy + 404.
2026-05-14 01:07:09 -04:00
CarterPerez-dev e3f9ff48f9 feat(canary): event.CountActiveDedup + token.DeleteByManageID for manage page
Adds the two service primitives Phase 11's manage handler needs.

- event.Service.CountActiveDedup(ctx, tokenID): Redis SCAN over
  dedup:trigger:{tokenID}:* in batches of 100, sums (value-1) for each
  key (value=1 = first trigger fired notification, value=N>1 = N-1
  silenced). Returns 0 when no rdb is configured.
- token.Service.DeleteByManageID: thin wrapper around
  Repository.DeleteByManageID; ServiceRepository interface gains the
  method so test fakes can implement it (fakeRepo updated). Returns
  ErrNotFound on no-rows.

Tests cover: empty pattern returns 0, first-trigger-only returns 0
(notify fired, nothing silenced), aggregation across IPs sums correctly,
other tokens' keys are excluded by the prefix match, nil rdb returns 0.
2026-05-14 01:06:45 -04:00
CarterPerez-dev 41b9d2c911 fix(canary-phase10): escape geo wrapping parens for MarkdownV2 before rollup
Audit catch (code-reviewer agent). formatGeo emitted literal `(` and `)`
around the geo block (e.g. `(Toronto, CA)`), but `(` and `)` are in the
MarkdownV2 reserved set when used outside `[text](url)` link delimiter
positions. Telegram would reject the entire message with
`Bad Request: can't parse entities: Character '(' is reserved`.

Fix: write the wrapping parens as `\(` and `\)` literals in the three
parens-construction switch arms. The dynamic field values inside still
flow through EscapeMD as before. The `[View full event timeline](url)`
inline link is left alone — its `(` `)` ARE link delimiters and per V2
rules must NOT be escaped (only `)` and `\` inside the URL portion need
escapes, and our manage URL has neither).

Regression test: TestSender_Send_MessageContainsKeyFields now asserts
`require.Contains(text, \\(Toronto, CA\\))` so substring matching can't
hide future regressions on the wrapping parens.
2026-05-14 00:47:08 -04:00
CarterPerez-dev 82085711b6 test(canary): testcontainers integration test for trigger + dedup + retention
Closes deferred Phase-9 item 1 (testcontainers full-flow test) and
exercises the Phase 10 dedup gate, retention loop, and notify writeback
end to end.

internal/event/integration_test.go (build tag integration):

- TestIntegration_FullCreateAndTriggerFlow: spins up testcontainers
  Postgres + miniredis, builds the same chi router shape main.go uses
  (RequestID + Recovery + tokenH.RegisterTriggerRoutes + /api subrouter
  with /tokens POST). POSTs a webbug create, then GETs /c/{id} twice
  from the same CF-Connecting-IP. Asserts: only one notification fires
  (capturingSender callCount == 1), both events recorded (CountByToken
  == 2), one event marked NotifySent + one NotifyDeduped, dedup INCR
  bumped the Redis counter to "2", trigger_count == 2 on the token.

- TestIntegration_DifferentIPsBothNotify: same setup, three different
  IPs each fire their own notification (no cross-IP dedup).

- TestIntegration_DedupTTLExpiry: builds an event.Service with 50ms
  DedupTTL, records two events (second deduped), miniredis.FastForward
  past TTL, third record notifies again — proves the SetNX TTL is the
  authoritative window boundary.

- TestIntegration_RetentionLoopPrunes: inserts 7 events, runs
  RunRetentionLoop with limit=3, asserts the table converges to 3
  rows (newest kept) and the loop exits cleanly on context cancel.

The capturingSender is a notify.Sender impl; this lets us assert
on whether the dispatch goroutine actually called Send() without
mocking the HTTP layer of the real telegram/webhook senders.
2026-05-14 00:32:28 -04:00
CarterPerez-dev 0524d84c89 chore(canary): scope gosec G101/G107/G704 to outbound HTTP packages
Closes deferred Phase-9 item 4. Phase 9's rollup added G107 + G704 to
the global gosec.excludes as a stop-gap so the Turnstile siteverify
call wouldn't flag SSRF taint. With Phase 10 introducing webhook
sender that takes user-supplied URLs (validated at the call site via
webhook.validateURL before the request is built), the global excludes
hide the very kind of issue we want flagged anywhere new outbound HTTP
lands.

- gosec.excludes: drop G107 + G704 globally; keep only G104 + G706
  (the codebase-wide errcheck/subprocess policies).
- exclusions.rules path-scope G107|G704 to:
  * internal/notify/telegram/sender.go (operator-config bot URL)
  * internal/notify/webhook/sender.go (user URL with validateURL gate)
  * internal/turnstile/verifier.go (operator-config siteverify URL)
- exclusions.rules path-scope G101 to internal/config/config.go where
  the env-var-name map (WEBHOOK_HMAC_SECRET, TURNSTILE_SECRET_KEY,
  OPERATOR_TOKEN, etc.) trips the hardcoded-credentials regex on
  literal env var names — false positives, not actual secrets.

Any new client.Do(req) outside these three packages will surface
G107 at the call site and force the author to add URL validation.
2026-05-14 00:32:07 -04:00
CarterPerez-dev 2799045ee7 refactor(canary): consolidate realIP into middleware across 5 generators
Closes deferred Phase-9 item 8. Phase 9 promoted realIP / lastNonEmptyXFF /
OptionalHeader to internal/middleware and refactored webbug only; the
other generators kept their byte-identical local copies. Sweeping them
all over now while the touch surface is already open for Phase 10.

- docx, envfile, kubeconfig, pdf, slowredirect: drop the local
  realIP / lastNonEmptyXFF / optionalHeader functions plus the
  CF-Connecting-IP / X-Forwarded-For / X-Real-IP header constants.
  Trigger paths now call middleware.RealIP / middleware.OptionalHeader
  directly. Drops the unused "net" import from each.
- slowredirect/fingerprint_handler.go: same treatment for the
  AttachFingerprint call site.

No behavior change — middleware.RealIP is byte-identical to what was
previously inlined per generator; verified by re-running the per-package
trigger tests post-refactor.
2026-05-14 00:31:49 -04:00
CarterPerez-dev f9b012699a feat(canary): wire event + notify into runtime + create-tier rate limits
Swaps the Phase-9 directEventRecorder/mysqlEventRecorder shims for
real event.Service.Record via an eventRecorderAdapter. Constructs
notify.Service with telegram + webhook senders. Spawns the retention
loop goroutine off the main wait group; gracefulShutdown now also
notifySvc.Wait()s for in-flight notifications to drain.

Closes deferred Phase-9 items 2 (real event recorder), 3 (nested
create-tier rate limits), 5 (FingerprintRecorder wiring), 7 (drop
required tag on TurnstileResp).

- cmd/canary/main.go:
  * buildEventStack constructs telegram + webhook senders and registers
    them on notify.Service. event.Service wired with eventRepo (Store +
    StatusWriter), tokenRepo (TokenIncrementer), redis client, notifier.
  * eventRecorderAdapter and a single mysqlEventRecorder both delegate
    to event.Service.Record(ctx, t.NotifyInfo(), evt) — one path,
    same dedup + notify semantics for HTTP triggers and mysql triggers.
  * fingerprintRecorderAdapter wraps event.Repository.AttachFingerprint
    with the configured window (defaults to 5 min) so the slow-redirect
    fingerprint POST writes through.
  * spawnRetentionLoop runs event.Service.RunRetentionLoop on the
    shared wait group; configured interval + per-token limit gate it.
  * /api/tokens POST gains nested create-tier limits per spec §11.3:
    LimitCreateMin (5/min) + LimitCreateHour (20/hr) chained ahead of
    Turnstile, with their own fingerprint-keyed Redis namespaces so
    they don't share buckets with the read-tier limiter.
  * Shutdown order: server → telemetry → redis → db, then
    notifySvc.Wait() to drain in-flight goroutines, then wg.Wait()
    for retention loop + mysql listener.

- internal/config/config.go:
  * NotifyConfig: dedup_ttl, send_timeout, max_tries, max_elapsed,
    initial_interval, retention_interval, retention_limit,
    webhook_hmac_secret, telegram_api_base, fingerprint_window.
  * RateLimitConfig: create_min_rate / burst, create_hour_rate / burst.
  * Defaults + env mappings (NOTIFY_*, RATE_LIMIT_CREATE_*, plus the
    spec'd WEBHOOK_HMAC_SECRET).

- internal/token/dto.go: drop the `validate:"required"` tag on
  cf_turnstile_response. The middleware (TurnstileVerify) is the sole
  authority — it bypasses on empty SecretKey for dev mode and would
  reject empty-token submissions in prod via the Verifier's own
  ErrEmptyToken path. The validator tag was forcing tests to pass
  a placeholder string in dev mode which conflicted with the bypass.
2026-05-14 00:31:24 -04:00
CarterPerez-dev 0757d9f196 feat(canary): event + notify domain — contracts, senders, services
Lays the domain layer for Phase 10 notification dispatch.

- event/contract.go: NotifyInfo (token-shape DTO that breaks the would-be
  event→notify→token cycle), Notifier interface, TokenIncrementer interface,
  Store interface (Insert+UpdateNotifyStatus+PruneToLimit).
- token.Token.NotifyInfo() helper bridges *token.Token → event.NotifyInfo
  for the main.go adapter.
- notify/types.go: Sender + StatusWriter interfaces.
- notify.Service: per-channel sender registry, async fire-and-forget
  Notify with bounded sendTimeout, WaitGroup for graceful drain, status
  writeback (sent / failed) on the configured StatusWriter.
- telegram.Sender: POST /bot{TOKEN}/sendMessage with MarkdownV2-escaped
  body, cenkalti/backoff/v5 retry (3 tries / 30s window), permanent on
  4xx, 10s overall + 5s connect timeouts. Note: spec body said
  parse_mode=Markdown but escape table is the V2 set; using MarkdownV2
  keeps the escape rules consistent.
- webhook.Sender: POST user URL with versioned JSON envelope (§10.4),
  optional HMAC-SHA256 signing via X-Canary-Signature, URL validation
  at the call site (rejects non-http(s), missing host, userinfo). Same
  retry/timeout policy as telegram.
- event.Service: Record(ctx, info, evt) inserts → IncrementTriggerCount
  → Redis SetNX dedup gate (15m TTL, fail-open on Redis error). First
  trigger calls notifier; duplicates INCR + UpdateNotifyStatus(deduped).
- event.Service.RunRetentionLoop(ctx, interval, limit) tickered prune.

Adds cenkalti/backoff/v5 + miniredis/v2 to go.mod for backoff API and
unit-test Redis fakes.
2026-05-14 00:30:42 -04:00
CarterPerez-dev 6c37082598 fix(canary-phase9): plumb mysql public host/port through to generator
Audit found that registry.Build was calling mysql.New() with hardcoded
localhost:3306 defaults, ignoring cfg.MySQL.PublicHost and PublicPort.
Result: every emitted mysql:// connection string said
"mysql://canary_<id>@localhost:3306/internal_db" regardless of actual
deployment address — useless for any non-localhost deployment.

Fix:
  - registry.Config: adds MySQLPublicHost + MySQLPublicPort fields
  - registry.Build: switches between mysql.New() and
    mysql.NewWithAddress(host, port) based on config
  - cmd/canary/main.go: passes cfg.MySQL.PublicHost/PublicPort
    through to registry.Build
  - registry_test.go: new TestBuild_MySQLUsesConfiguredPublicAddress
    asserts the connection string reflects configured host/port

Other Phase 9 audit observations DEFERRED to Phase 10 (not blocking
rollup):
  - §11.3 create-tier rate limit on POST /api/tokens (Turnstile
    already provides spam protection; tiered limits are
    belt-and-suspenders)
  - scoping the gosec G107/G704 exclude to specific packages
    (revisit when Phase 10's webhook sender lands and needs
    case-by-case URL validation)
  - TurnstileResp dto required-tag clashes with dev-mode bypass
    (low severity — current behavior is sound)
  - /api/m/{manage_id} GET/DELETE (spec §8.1 lines 740-741) — Phase 11
  - FingerprintRecorder is nil at startup (slowredirect fingerprint
    handler wiring) — Phase 10
  - mysql_username metadata write in token.Service (mysql trigger
    lookup goes by token ID, not username — low impact)

Pre-rollup gate clean at HEAD:
  - go build ./... + go vet ./... clean
  - go test -race -timeout=60s ./... all pass (~50 new test cases)
  - go test -tags=integration -race -timeout=300s ./internal/token/...
    ./internal/event/... all pass
  - golangci-lint run ./... → 0 issues
  - grep -rn "//nolint" --include="*.go" returns nothing
2026-05-13 15:27:21 -04:00
CarterPerez-dev 8d1016b87d feat(canary): full wire-up + webbug refactor + mysql goroutine
Phase 9 tasks 9.7 + 9.8 + closes Phase 8 task 8.5 deferral.

config.Config additions:
  - Canary    {BaseURL, ManageURL}        — public URLs for trigger
                                            + manage redirects
  - Turnstile {SecretKey, SiteKey}        — Cloudflare Turnstile
                                            (empty SecretKey = dev bypass)
  - MySQL     {Enabled, Addr, PublicHost, — fake TCP listener config
               PublicPort}

cmd/canary/main.go full wire-up per spec §6.3 + §11.1:
  - Global middleware: RequestID → Logger → Recovery → SecurityHeaders
  - /healthz: no further middleware (router-mounted directly)
  - /c/{id}, /c/{id}/fingerprint, /k/{id}, /k/{id}/*: mounted at
    router root, OUTSIDE /api subrouter, so they skip CORS + rate
    limit (spec §11.1 line 1552 — we want to record EVERY hit)
  - /api subrouter: CORS → RateLimit (KeyByFingerprint, FailOpen=true)
  - /api/tokens/types: no further middleware (read-only public list)
  - /api/tokens POST: + TurnstileVerify middleware
  - mysql goroutine: spawned via spawnMySQLListener iff
    cfg.MySQL.Enabled; uses mysqlTokenLookup + mysqlEventRecorder
    adapters bridging mysql.{TokenLookup,EventRecorder} to
    token.Service + event.Repository

Adapter types in cmd/canary/main.go:
  - registryAdapter: bridges registry.Registry (map type) to
    token.Registry interface (Get method)
  - directEventRecorder: bridges token.EventRecorder to
    event.Repository.Insert + token.Repository.IncrementTriggerCount
    (synchronous; Phase 10 swaps in event.Service for dedup + async
    notify)
  - mysqlTokenLookup, mysqlEventRecorder: same shape for the TCP
    mysql handler

webbug refactor (task 9.8):
  - Delete local realIP/lastNonEmptyXFF/optionalHeader copies (~30 LOC)
  - Use middleware.RealIP and middleware.OptionalHeader instead
  - Tests still pass (the IP-precedence suite in webbug_test.go
    exercises middleware.RealIP behavior end-to-end via the Trigger
    contract)

Pre-rollup gate clean at HEAD:
  - go build ./... + go vet ./... clean
  - go test -race -timeout=60s ./... all pass
  - go test -tags=integration -race -timeout=300s ./internal/token/...
    ./internal/event/... all pass
  - golangci-lint run ./... → 0 issues
  - grep -rn "//nolint" --include="*.go" returns nothing

DEFERRED (not in Phase 9 scope):
  - Phase 9 task 9.9 integration test (full POST /api/tokens against
    testcontainers) — defer to Phase 10's broader integration suite
    along with event.Service + notify.Service end-to-end coverage
  - Phase 10 brings: event.Service with dedup + async notify, swaps
    in for the directEventRecorder/mysqlEventRecorder adapters
  - Phase 13 brings: geoip.Service wired into event recording
2026-05-13 15:20:01 -04:00
CarterPerez-dev 0a456f97ae feat(canary): token HTTP handlers (API + trigger routes)
Phase 9 task 9.6.

Routes:
  RegisterAPIRoutes(r):
    GET  /tokens/types  → static list of 7 type descriptors
    POST /tokens        → CreateToken (validation + service.Create)

  RegisterTriggerRoutes(r):
    GET  /c/{id}                 → HandleTrigger (webbug, slowredirect,
                                    docx, pdf, envfile use this path)
    POST /c/{id}/fingerprint     → HandleFingerprint (slowredirect)
    HandleFunc /k/{id}           → HandleTrigger (kubeconfig, bare path)
    HandleFunc /k/{id}/*         → HandleTrigger (kubeconfig wildcard,
                                    any HTTP method)

HandleTrigger flow:
  - extract token id from chi.URLParam
  - service.GetByID (returns nil-nil for not-found)
  - if token exists and !enabled, treat as unknown
  - resolve generator (known token → its type; unknown + path
    prefix /k/ → kubeconfig; unknown otherwise → webbug)
  - generator.Trigger(ctx, tok, r) → event + response
  - record event via EventRecorder interface if present (Phase 10
    wires real event.Service)
  - write response: headers + body OR 302 + Location for redirect

CreateToken flow:
  - 64KB body cap via http.MaxBytesReader
  - JSON decode (bad → 400 BAD_JSON)
  - extract fingerprint + real IP via middleware helpers
  - service.Create
  - error switch: validation/unknown-type/destination/include-keys
    map to 400; generator-failure to 500
  - happy path: 201 with {token, artifact} envelope per spec §8.2

ArtifactJSON discriminator pattern:
  - kind=url     → url + destination_url
  - kind=file    → filename + content_type + content_b64 (base64)
  - kind=text    → filename + content_type + content (raw)
  - kind=conn... → connection_string

Tests (~10 cases): 7-type list, happy path with envelope assertion,
bad JSON, validation failure, known-token records event + writes
response, unknown-token still returns shape (defense-in-depth),
disabled-token treated as unknown, fingerprint without recorder
returns 204, ArtifactJSON discriminator for all 4 kinds.

Phase 9 task 9.6 discharged. Wire-up + config + webbug refactor +
integration test next.
2026-05-13 15:16:41 -04:00
CarterPerez-dev 019bfda437 feat(canary): token service + contract relocation
Phase 9 task 9.5.

Resolved a long-standing import-cycle constraint: the Generator
interface + Artifact + ArtifactKind + TriggerResponse types now live
at internal/token/contract.go (the token package, which both the
service and concrete generators can import). The
internal/token/generators/generator.go file now re-exports these
as Go type aliases for backwards compatibility — concrete generators
(webbug/slowredirect/docx/pdf/kubeconfig/envfile/mysql) keep their
existing `generators.Artifact` / `generators.KindFile` references
unchanged.

This unlocks token.Service.Create, which would otherwise be unable
to type-reference Artifact (since generators imports token for
token.Type/token.Token).

token.Service:
  - Create(ctx, req, fp, ip) (*Token, Artifact, error):
      - go-playground/validator on the request DTO
      - type-specific metadata validation:
        - slowredirect → metadata.destination_url required, must be
          http:// or https://
        - envfile → metadata.include_keys (if present) must be a
          subset of {aws,stripe,github,db}; malformed JSON rejected
        - others → no extra validation
      - registry.Get(type) → ErrUnknownGeneratorType on miss
      - generateTokenID: 12-char [a-z0-9] via crypto/rand
      - uuid.NewString for manage_id
      - call Generator.Generate (which may mutate t.Metadata for
        mysql's mysql_username persist)
      - repo.Insert; rollback semantics: if generator fails, no
        persistence happens
  - GetByID / GetByManageID: proxy to repo, return (nil, nil) on
    not-found (so handlers can distinguish 404 from real errors)
  - IncrementTriggerCount: proxy to repo
  - TriggerURL / ManageURL: URL builders so handlers don't duplicate
    path joining
  - Generator(t Type): registry lookup for handler dispatch

Tests (~15 cases): ID + manage_id format regexes, repo persistence,
generator call, unknown type, validation failure, slowredirect
metadata variants (missing/invalid-scheme/valid), envfile include_keys
(invalid/valid), generator error path skips persistence, distinct IDs
across 30 calls, GetByID nil-nil semantics, URL builders.
2026-05-13 15:13:43 -04:00
CarterPerez-dev 537b0e3fd8 feat(canary): turnstile verifier + middleware
Phase 9 task 9.2 + 9.3. Cloudflare Turnstile integration for
POST /api/tokens spam protection.

turnstile/verifier.go:
  - Verifier{secret, verifyURL, client, rdb}
  - Verify(ctx, token, fingerprint) error:
      - empty secret → dev-mode bypass (return nil)
      - empty token → ErrEmptyToken
      - cache hit on "turnstile:fp:<fp>" → return nil (5-min TTL)
      - else: POST to challenges.cloudflare.com/turnstile/v0/siteverify
        with secret + response, decode, return ErrVerifyFailed on
        false or wrap network err
      - on success, write cache entry (best-effort, logged on error)
  - RedisClient interface (Get + SetEx) so tests can use a fake
    without miniredis dep
  - HTTP client timeout 10s, defaults to DefaultVerifyURL constant

middleware/turnstile.go:
  - TurnstileVerifier interface (Verify) decouples middleware from
    the concrete verifier — lets tests inject a fake
  - TurnstileVerify(v) middleware: extracts token from
    CF-Turnstile-Response header OR JSON body's cf_turnstile_response
    field (header wins); preserves request body for downstream
    handlers by re-wrapping io.NopCloser; on verify failure returns
    400 with {success:false, error:{code:"TURNSTILE_FAILED"}}

.golangci.yml: adds G104, G706, G107, G704 to gosec excludes.
G704/G107 are SSRF taint-analysis rules that fire on any HTTP client
.Do call where the URL came from a struct field. In this codebase
all outbound HTTP calls go to operator-configured endpoints (Turnstile
siteverify, future Phase 10 Telegram), with user-supplied webhook
URLs validated at the call site before reaching client.Do. The
global excludes are policy-level (matches the G104/G706 precedent
of "this codebase doesn't do X").

Tests (~14 cases):
  - verifier: empty-secret bypass, empty-token error, success cached
    by fingerprint, cache miss for different fingerprint, failed
    siteverify returns ErrVerifyFailed, nil-redis works without
    caching, network error wrapped (and NOT ErrVerifyFailed)
  - middleware: header token passes, body token passes, body
    preserved for downstream, failure returns 400 with code, header
    wins over body when both present, empty-token still calls
    verifier
2026-05-13 15:08:37 -04:00
CarterPerez-dev c988e4b902 feat(canary): middleware additions (realip, fingerprint, recovery)
Phase 9 first commit. Three middleware additions that the token
handler + service will consume.

  - realip.go: RealIP(r) + OptionalHeader(v) + lastNonEmptyXFF —
    promotion of the helpers currently duplicated across webbug/
    slowredirect/docx/pdf/kubeconfig/envfile/mysql generators. The
    bodies are byte-identical to those copies. Phase 9 task 9.8
    refactors webbug to use this shared helper next; the other
    generators' duplication remains until Phase 10's Trigger plumbing
    refactor (Phase 9 keeps the scope to webbug per the plan)
  - fingerprint.go: ExtractFingerprint(r) = hex(sha256(realIP|UA))[:16]
    + KeyByFingerprint(r) = "ratelimit:fp:" + fp, per spec §11.2
    lines 1568-1591. The 16-char prefix is a deliberate space/
    collision trade-off documented in the spec — full sha256 is 64
    chars, 16 chars = 64 bits ≈ 1e19 keyspace, fine for fingerprint
    rate-limiting where collisions are caught by the rate limit
    granularity anyway
  - recovery.go: Recovery(logger) middleware — defer recover(),
    logs panic + request_id + path + stack, writes a 500 with the
    project's standard {success:false, error:{code,message}}
    envelope. Per spec §11.1 line 1543

Tests (~16 cases): 11 source-IP precedence cases matching the
generator suites + OptionalHeader empty/whitespace/value + fingerprint
determinism/length/hex-format/different-IPs-distinct + KeyByFingerprint
prefix + recovery panic-returns-500 + recovery passes-through-non-panic
+ recovery logs request ID through the RequestID middleware chain.

Phase 9 task 9.1 + 9.4 discharged.
2026-05-13 15:04:29 -04:00
CarterPerez-dev fe80c94a73 feat(canary): mysql generator + register in registry
Phase 8 third commit. Closes the generator set — all 7 token types are
now registered.

mysql/generator.go:
  - Generator with publicHost / publicPort / database fields. New()
    returns localhost:3306/internal_db defaults; NewWithAddress(host,
    port) overrides for Phase 9 wire-up
  - Generate writes mysql_username into t.Metadata (json.RawMessage
    merge via setMySQLUsername helper — same defensive map[string]
    json.RawMessage pattern as envfile's extractIncludeKeys: malformed
    existing metadata is replaced rather than erroring, existing
    fields preserved, prior mysql_username overwritten)
  - Returns Artifact{Kind: KindConnectionString, ConnectionString:
    "mysql://canary_<id>@<host>:<port>/internal_db"} per spec §9.7
    lines 1345-1352. baseURL ignored (mysql connection strings don't
    use it)
  - Trigger(ctx, t, r) returns (nil, nil, ErrHTTPTriggerNotSupported)
    sentinel error — mysql triggers fire over the TCP listener (see
    server.go/handler.go), not via the HTTP router. Phase 9's router
    will not mount any HTTP route to mysql.Trigger; this is defense
    for accidental programmatic calls
  - Note on spec §9.7 line 1343: `t.Metadata["mysql_username"] =
    username` doesn't compile against the real Token.Metadata
    (json.RawMessage). The setMySQLUsername helper is the sanctioned
    *string-shape adaptation, matching the slowredirect/envfile
    metadata patterns

mysql/generator_test.go (~11 cases):
  - Type / KindConnectionString / default connection string format /
    custom address / canary_ prefix invariant / metadata persistence
    on empty token / preservation of existing metadata fields /
    malformed-metadata fallback / stale-mysql_username overwrite /
    baseURL ignored / Trigger returns ErrHTTPTriggerNotSupported with
    nil event + nil response

registry update: cardinality 6 → 7; pending list now empty.
TestBuild_AllSevenGeneratorsRegistered replaces the
TypesPresentInPhaseN test pattern from prior phases, asserting all 7
generator types are present (closes the generator set; future phases
add services/handlers, not generators).

DEFERRED TO PHASE 9 (task 8.5): wiring `go mysql.Run(ctx, addr,
handler)` into cmd/canary/main.go. The handoff anti-relitigation set
documents "Phase 9 wires the registry into main.go" — the mysql
listener goroutine fits naturally into Phase 9's main.go work
alongside config.Config.MySQL.Enabled, config.Config.MySQL.Addr,
the token.Service (for TokenLookup), and the event.Service (for
EventRecorder). Wiring it in Phase 8 would require partial config
additions that Phase 9 then re-touches.

Pre-rollup acceptance gate clean at HEAD:
  - go build ./... + go vet ./... clean
  - go test -race -timeout=60s ./... all packages pass (~115 tests
    added across Phase 8 alone)
  - go test -tags=integration -race -timeout=300s pass
  - golangci-lint run ./... → 0 issues.
  - zero //nolint pragmas anywhere
2026-05-13 14:55:37 -04:00
CarterPerez-dev 4d4d4951ba feat(canary): mysql server + connection handler
Phase 8 second commit. TCP listener orchestration + per-connection
business logic.

server.go (~70 LOC):
  - mysql.Run(ctx, addr, ConnectionHandler) error
    Listens via net.ListenConfig (context-aware), accepts in a loop,
    dispatches each connection to handler.HandleConnection in its own
    goroutine, waits on context cancellation to close the listener
    and drain in-flight connections via WaitGroup
  - ConnectionHandler interface (single HandleConnection method) so
    tests can substitute a stub without standing up real handler deps
  - Logs listener bind + accept errors via slog; suppresses net.ErrClosed
    on graceful shutdown so the shutdown path is silent

handler.go (~170 LOC):
  - TokenLookup interface (GetByID) and EventRecorder interface (Record)
    decouple the handler from Phase 9's token.Service and Phase 10's
    event.Service. Production wire-up in Phase 9 (deferred — see Phase
    8 rollup notes on task 8.5 deferral)
  - HandleConnection writes HandshakeV10 → reads HandshakeResponse41 →
    extracts username → strips canary_ prefix → looks up token →
    records event with mysql_username + mysql_client_capabilities +
    mysql_client_charset in event.Extra (mirrors kubeconfig's
    kubectl_* forensic capture) → writes ERR_Packet 1045 → defers
    close. 10-second connection deadline.
  - Defense-in-depth: usernames without canary_ prefix dropped
    silently, unknown tokens dropped silently, lookup errors dropped
    silently — no behavior signal to a probing attacker
  - Nil EventRecorder tolerated (writes ERR but skips event) — Phase 9
    can wire the handler before event.Service exists if needed

Tests (~14 cases):
  - server: nil-handler rejected, basic dispatch, 10 concurrent
    connections, context cancellation closes listener cleanly,
    invalid address rejected
  - handler: handshake sent first, non-canary username drops silently,
    known token records event AND sends ERR, event.Extra carries
    all three kubectl-equivalent fields with correct hex formatting,
    unknown token records nothing, lookup error silent, nil
    EventRecorder tolerated (still sends ERR), bad auth packet
    silent
  - Uses real net.Listener + net.Conn pairs (not mocks) for
    behavioral fidelity — TCP wire-protocol tests should hit real
    sockets

go test -race -timeout=60s passes. golangci-lint clean.
2026-05-13 14:53:22 -04:00
CarterPerez-dev 712202abc0 feat(canary): mysql wire protocol (handshake + auth + ERR packets)
Phase 8 first commit. Pure encoding/decoding layer for the MySQL
wire protocol — no I/O orchestration, no DB lookups, no business
logic. Three primary functions plus utility helpers, all driven by
the byte-exact spec §9.7 lines 1363-1403:

  - BuildHandshakeV10(connID, authData[20]) []byte
    Server's initial greeting packet (0x0a protocol version,
    "5.7.40-canary" server version, random auth-plugin-data split
    into 8+12 byte parts, capability flags 0xf7ff / 0x81ff,
    utf8mb4_unicode_ci charset, mysql_native_password plugin name).
    Wraps in 3-byte LE length + 1-byte sequence ID (0x00).

  - ReadClientAuth(r io.Reader) (*ClientAuth, error)
    Parses HandshakeResponse41. Returns the username (null-terminated,
    extracted starting at byte offset 32 after the 4+4+1+23-byte
    fixed header) plus the client's capabilities, max packet size,
    and charset for forensic richness. Defensive bounds checking
    against ErrInvalidPayload / ErrUsernameMissing / ErrShortPacket /
    ErrPacketTooLarge sentinels. 64KB max packet size cap.

  - BuildAccessDeniedErr(username, sourceHost string) []byte
    Standard MySQL ERR_Packet 1045 with SQL state "28000" and the
    exact "Access denied for user '%s'@'%s' (using password: YES)"
    message kubectl and the mysql client both display verbatim.
    Sequence ID 0x02 (after handshake=0 and client auth=1).

Plus crypto/rand-backed helpers NewRandomAuthData (20 bytes for the
challenge) and NewRandomConnectionID (uint32). Consistent with the
crypto/rand discipline from Phase 0 supplement, Phase 5 (pdf), Phase 7
(envfile).

Tests (~24 cases): byte-exact assertions on packet headers, payload
layout, sequence IDs; round-trip Build/Read for 5 username variants;
error paths (short packet, missing username terminator, empty reader,
oversize packet rejected mid-stream); randomness distinctness (50
calls → near-50 unique values).

Handler + server land in next commit; generator + registry in the
one after. Trigger interface conformance for the mysql Generator will
be a sentinel-error no-op (mysql triggers over TCP, not HTTP — Phase
9 router doesn't mount any HTTP path to mysql.Trigger).

go test -race -timeout=60s ./internal/token/generators/mysql/...
passes, golangci-lint clean.
2026-05-13 14:49:03 -04:00
CarterPerez-dev 1cde61efbb feat(canary): register envfile generator in registry
Sixth entry in the registry map. Cardinality bumps 5 → 6; pending list
shrinks to {mysql} (1 remaining).

  - registry.go: adds the import + token.TypeEnvfile: envfile.New()
  - registry_test.go:
      - TestBuild_RegistersEnvfile added (mirrors RegistersKubeconfig)
      - TypeEnvfile removed from TestBuild_PendingTypesNotYetRegistered
      - TestBuild_OnlyExpectedTypesPresentInPhase6 renamed → InPhase7,
        cardinality assertion bumped 5 → 6

Pre-audit gate clean at HEAD: go build / vet / test -race / integration
tests / golangci-lint run all pass with 0 issues.
2026-05-13 14:37:02 -04:00