Skip to content

Service last-access accounting - #451

Open
runleveldev wants to merge 17 commits into
mainfrom
accounting-pr
Open

Service last-access accounting#451
runleveldev wants to merge 17 commits into
mainfrom
accounting-pr

Conversation

@runleveldev

@runleveldev runleveldev commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

What & why

Records a per-service last-access timestamp so the platform can tell which proxied services are actually being used (e.g. to spot idle containers). Collection happens on the agent's nginx proxy with zero impact on request processing, and the manager is updated at most once per service per 10 minutes.

How it works

Agent (nginx proxy) — a static njs module (agent/njs/accounting.js) wired into nginx.conf.ejs:

  • HTTP services record via a parallel mirror subrequest (js_content accounting.http_record), so it runs alongside proxy_pass and never adds latency to the client response. mirror_request_body off keeps uploads unbuffered (Why uploads crawled behind our proxy — and the 15× fix #395). The mirror runs after the auth gate, so auth-rejected/scanner traffic doesn't count.
  • TCP/UDP services record via js_access accounting.stream_record (fire-and-forget, connection setup never waits).
  • A shared dict with timeout=10m + atomic dict.add() is the entire throttle: exactly one report per service per 10-minute window, race-free across workers, and preserved across nginx reloads (shm inheritance).
  • Everything is fail-open: any accounting error is logged and swallowed; behavior is identical with and without the module.

Manager (create-a-container):

  • New Services.lastAccessedAt column + migration.
  • New MVC resource POST /api/v1/services/:id/last-access — auth mirrors the agent check-in precedent (localhost bypass OR admin Bearer), a single UPDATE statement, timestamp stamped server-side (agent clocks never trusted). 204 / 400 / 401 / 403 / 404.
  • Service ids added to the agent config snapshot; container serializer exposes per-service lastAccessedAt plus a container-level rollup (max across services). OpenAPI + client types updated.

Client: shared formatRelativeTime util + a sortable "Last Access" column on the containers dashboard; AgentsListPage refactored onto the shared formatter.

Notable design decisions (validated against Debian trixie)

  • A capability spike found trixie ships njs 0.8.9, which supports neither js_engine qjs nor state= dict persistence (both need 0.9.1). The module runs on the default njs engine (named accounting.js), and there's no state=: reloads preserve the throttle, but a full nginx restart resets the windows — one extra benign report per active service. Accepted.
  • trixie's njs stream module is built without NGX_STREAM_SSL, so stream ngx.fetch() can't do TLS. Stream servers therefore report to a localhost-only http relay listening on a unix socket (/run/nginx-osaas-relay.sock, js_content accounting.relay) that forwards to the manager over TLS with the Bearer credential. The relay validates the path against ^/api/v1/services/(\d+)/last-access$ — the embedded credential can only ever produce a last-access stamp. (resolver / js_fetch_timeout / js_fetch_trusted_certificate are declared once at http {} level and inherited.)
  • nginx.conf is now written 0600 (it embeds the admin API key); packaging adds libnginx-mod-http-js, libnginx-mod-stream-js, and ca-certificates.

Incidental fix (not part of the feature)

  • fix(create-a-container): rename Notifications ctid column — the notification-queue migration (from main) creates a ctid column, which Postgres reserves as a system column on every table, so CREATE TABLE fails with 42701 and the manager cannot boot on Postgres at all. (It slipped through because tests run on SQLite, where ctid is not reserved.) The physical column is renamed to containerId and the model maps its ctid attribute onto it via Sequelize's field:, so the API/JSON/query surface is unchanged. Surfaced here because the rebase onto current main pulled this migration into the branch and it blocks startup; verified against real Postgres 16 (create + ctid attribute round-trip).

Testing

  • Agent: first test suite added (node --test) — render tests pinning the accounting hooks (presence in service blocks, absence in default/landing/wildcard/503 blocks, stream relay unix socket, single API-key occurrence, http-level fetch-directive hoisting).
  • Manager: jest/supertest for the endpoint (localhost/admin/non-admin/unknown-id/CSRF) + serializer rollup + snapshot-id/ETag-determinism.
  • Full rendered config validated with nginx -t in a debian:trixie container (all branches), plus a live two-hop stream→socket-relay→manager delivery test.
  • ctid fix verified against real Postgres 16 (CREATE TABLE succeeds; Notification.create({ ctid }) / findOne({ where: { ctid } }) round-trip onto the containerId column).

Notes for reviewers

  • CodeQL js/missing-rate-limiting on the new /services/:id/last-access route was dismissed as accepted debt: it's the same trust model as the existing agent check-in (agents.js:30, which carries the identical open alert), the repo has no rate-limiting middleware today, and the endpoint is machine-facing and throttled to ≤1 req/service/10min upstream in nginx.
  • Reviewed task-by-task plus a final whole-branch review (clean, 0 Critical/Important). The highest-risk surfaces — relay credential confinement and the CSRF/localhost/Bearer auth interaction — were independently confirmed to hold.

Copilot AI lite review requested due to automatic review settings August 12, 2026 14:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds end-to-end “service last-access” accounting so the platform can surface which proxied services (and containers) are actively used, with throttled reporting from the agent proxy and server-stamped persistence in the manager, plus UI display support.

Changes:

  • Agent: inject njs-based accounting hooks into rendered nginx config (HTTP mirror + stream access), including a localhost relay for stream TLS limitations.
  • Manager: add Services.lastAccessedAt + migration, expose POST /api/v1/services/:id/last-access, and include per-service + container rollup serialization + OpenAPI updates.
  • Client: add shared formatRelativeTime and display/sort a “Last Access” column; refactor Agents list to use the shared formatter.

Reviewed changes

Copilot reviewed 27 out of 27 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
create-a-container/utils/agent-config.js Adds service.id into the agent config snapshot for accounting reports.
create-a-container/utils/tests/agent-config.test.js Tests snapshot includes service ids and ETag determinism.
create-a-container/routers/api/v1/index.js Mounts the new /services API router.
create-a-container/routers/api/v1/containers.js Serializes lastAccessedAt per-service and a container-level rollup.
create-a-container/routers/api/v1/tests/containers.serialize.test.js Unit tests for the container/service lastAccessedAt serialization and rollup.
create-a-container/resources/services/validator.js Zod validator for :id path param.
create-a-container/resources/services/service.js Service-layer logic for recording access + 404 on unknown id.
create-a-container/resources/services/router.js Adds POST /:id/last-access endpoint with localhost/admin auth model.
create-a-container/resources/services/repository.js Implements a single-statement UPDATE to stamp lastAccessedAt.
create-a-container/resources/services/controller.js Controller wiring for the record-access endpoint (204).
create-a-container/resources/services/tests/services.api.test.js API tests for localhost/admin/non-admin/unknown-id/invalid-id cases.
create-a-container/openapi.v1.yaml Documents new endpoint and adds lastAccessedAt fields to schemas.
create-a-container/models/service.js Adds lastAccessedAt column to the Sequelize model.
create-a-container/migrations/20260811000000-add-service-last-accessed-at.js Migration adding/removing Services.lastAccessedAt.
create-a-container/client/src/pages/agents/AgentsListPage.tsx Refactors check-in formatting to shared formatRelativeTime.
create-a-container/client/src/lib/types.ts Adds lastAccessedAt to container/service client types.
create-a-container/client/src/lib/formatRelativeTime.ts New shared relative-time formatting utility.
create-a-container/client/src/components/containers/ContainersDataGrid.tsx Adds “Last Access” column rendering/sorting using formatRelativeTime.
agent/test/nginx-template.test.js Adds render tests pinning nginx template accounting hooks.
agent/templates/nginx.conf.ejs Injects accounting module/dicts/vars, mirror subrequest, stream hooks, and relay server.
agent/src/types.ts Adds id to HttpService/StreamService types consumed by the agent.
agent/src/index.ts Passes agent config into apply to support secret-bearing templates.
agent/src/apply.ts Threads AgentConfig into render, renders nginx with accounting vars, and writes nginx.conf as 0600.
agent/package.json Adds a node test script.
agent/njs/accounting.js New njs module implementing HTTP/stream accounting + relay forwarding.
agent/Makefile Enables real test target and installs njs/ into the staged agent tree.
agent/.fpm Adds nginx js module and CA cert dependencies needed by accounting fetch.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

… to http{}

Move the stream-report relay off TCP 127.0.0.1:1985 to a unix socket
(/run/nginx-osaas-relay.sock), keeping it off the network entirely. The
stream fetch uses the njs unix-socket URL form (trailing ':' before the
URI) and sends an explicit Host header, which njs otherwise derives from
the socket path and nginx rejects with 400.

Hoist resolver, js_fetch_timeout, and js_fetch_trusted_certificate to the
http{} block so the mirror location and the relay server inherit them
instead of each carrying a copy.

Verified on debian:trixie (nginx 1.26.3 / njs 0.8.9): nginx -t passes on
the 0600 full render, and a stream connection delivers exactly one
correctly-shaped POST through the socket relay to the manager.
Comment thread create-a-container/resources/services/router.js Fixed
…eserved name)

The notifications migration (from the notification-queue feature) creates a
"ctid" column, which Postgres reserves as a system column on every table.
CREATE TABLE therefore fails with 42701 ("column name \"ctid\" conflicts with
a system column name"), and since migrations run at startup the manager cannot
boot on Postgres at all. It only slipped through because the test suite runs on
SQLite, where "ctid" is not reserved.

Rename the physical column to "containerId" and map the model's `ctid`
attribute onto it via Sequelize's `field:` option, so the API/JSON/query
surface (webhook payload, serializer, repository lookups, validator, tests)
is completely unchanged — only the column name differs.

The migration has never applied successfully on Postgres (it fails on CREATE
TABLE, so no table and no SequelizeMeta row exist), so editing the migration
in place is safe — no follow-up migration is needed.

Verified against real Postgres 16: CREATE TABLE now succeeds with a
containerId column, and Notification.create({ ctid }) / findOne({ where:
{ ctid } }) round-trip correctly onto that column.
…warn level

Two fixes to the nginx last-access accounting on the manager's own embedded
agent:

1. dnsmasq did not resolve "localhost". The rendered dnsmasq config uses
   `no-hosts`, so /etc/hosts (where 127.0.0.1 localhost lives) is ignored, and
   there was no other source for the name. nginx's `resolver 127.0.0.1` (this
   dnsmasq) therefore failed the njs accounting module's ngx.fetch to the
   manager's own http://localhost:3000, logged as:
     js: osaas accounting: "localhost" could not be resolved (3: Host not found)
   Add `address=/localhost/127.0.0.1` and `.../::1` so localhost (and
   *.localhost) resolve to loopback, per RFC 6761. Verified against real
   dnsmasq on trixie: localhost A -> 127.0.0.1, AAAA -> ::1, foo.localhost ->
   127.0.0.1, with no wildcarding of other names.

2. The accounting module logged via r.log/s.log, which write at njs's `info`
   level. The rendered error_log threshold is `notice`, one level above info,
   so every fail-open diagnostic was silently dropped. These lines only fire
   on actual failures (non-204 responses, fetch exceptions), so raise them to
   r.warn/s.warn (warning level) — visible under the `notice` threshold
   without turning on info-level noise.

Adds a dnsmasq conf.ejs render test pinning the localhost override.
… accounting

The service-accounting router (POST /api/v1/services/:id/last-access) had a
verbatim copy of the agent check-in's auth logic (localhost bypass, else
apiAuth + apiAdmin). Extract it once as localhostOrAdmin in middlewares/api
(next to apiAuth/apiAdmin, same lazy isLocalhostRequest require the csrfGuard
already uses) and have both routers use it. Behavior is unchanged; the
duplicated checkinAuth/accountingAuth functions are removed.
Comment thread create-a-container/resources/services/router.js Dismissed
Comment thread create-a-container/routers/api/v1/agents.js Dismissed
isLocalhostRequest was defined in middlewares/index but its only consumers
are in middlewares/api (csrfGuard and localhostOrAdmin), which imported it
twice via lazy require('./index') to sidestep a load-order cycle. Move the
function to api.js next to its callers: both lazy requires and the cycle
guard disappear, and the duplicate destructure is gone. Removed from the
index barrel's exports (nothing imports it from there); api.js exports it
for discoverability alongside the other guards.
Add session-authenticated coverage for POST /api/v1/services/:id/last-access:
a remote (X-Forwarded-For) session-cookie request is 403 without an
X-CSRF-Token and 204 with a valid one. Guards against the Bearer/localhost
CSRF exemptions silently regressing into a hole for cookie-authenticated
callers. Verified against real Postgres 16 (8/8 in the suite).

Addresses the reviewer note on services.api.test.js.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants