diff --git a/agent/.fpm b/agent/.fpm index f7646757..3c06e600 100644 --- a/agent/.fpm +++ b/agent/.fpm @@ -9,6 +9,9 @@ --depends nodejs --depends nginx --depends libnginx-mod-stream +--depends libnginx-mod-http-js +--depends libnginx-mod-stream-js +--depends ca-certificates --depends ssl-cert --depends dnsmasq --deb-no-default-config-files diff --git a/agent/Makefile b/agent/Makefile index 20a4abdd..fb051803 100644 --- a/agent/Makefile +++ b/agent/Makefile @@ -21,7 +21,7 @@ help: @echo "Targets:" @echo " deps install dependencies (npm ci)" @echo " build compile the TypeScript sources" - @echo " test run tests (none yet)" + @echo " test run tests" @echo " install stage files into DESTDIR (default /)" @echo " deb build the .deb package" @echo " rpm build the .rpm package" @@ -42,13 +42,13 @@ build: deps dev: build -# No test suite yet; kept as a no-op so the repo-wide `make test` passes. -test: +test: deps + npm test install: build $(INSTALL) -d $(DESTBIN) $(INSTALL_DATA) package.json $(DESTBIN)/ - cp -a dist templates node_modules $(DESTBIN)/ + cp -a dist templates njs node_modules $(DESTBIN)/ $(INSTALL) -d $(UNIT_DIR) $(INSTALL_DATA) contrib/systemd/opensource-agent.service $(UNIT_DIR)/ $(INSTALL_DATA) contrib/systemd/opensource-agent.timer $(UNIT_DIR)/ diff --git a/agent/njs/accounting.js b/agent/njs/accounting.js new file mode 100644 index 00000000..9fc343c2 --- /dev/null +++ b/agent/njs/accounting.js @@ -0,0 +1,111 @@ +/** + * Service last-access accounting for the opensource-server proxy. + * + * Loaded by nginx's js module (njs) in both the http and stream contexts. + * Each proxied request/connection checks a shared dict whose entries expire + * after 10 minutes (the zone's timeout=): a successful add() means no + * report was sent in the current window, so this handler claims it — + * atomically, across all workers — and POSTs to the manager, which stamps + * the service's lastAccessedAt with its own clock. Everything is fail-open: + * accounting can never affect the proxied request. + * + * Injected via nginx variables (see templates/nginx.conf.ejs): + * $osaas_service_id Services.id for this vhost / stream server + * $osaas_manager_url http-context: manager base URL, no trailing slash + * $osaas_api_key http-context: admin API key; empty for the manager's + * own agent, which reports over localhost without + * credentials + * $osaas_relay_url stream-context: localhost relay base URL — a unix + * socket, so it carries the njs form + * "http://unix:/path/to.sock:" (trailing colon + * separates the socket path from the request URI) + */ + +// `host` is only needed for unix-socket targets: njs would otherwise derive a +// Host header from the socket path ("host: /run/....sock:0"), which nginx +// rejects with 400. TCP targets pass it undefined and njs sets Host itself. +function report(base, apiKey, serviceId, host) { + const headers = {}; + if (apiKey) { + headers.Authorization = 'Bearer ' + apiKey; + } + if (host) { + headers.Host = host; + } + return ngx.fetch(base + '/api/v1/services/' + serviceId + '/last-access', { + method: 'POST', + headers, + }); +} + +/** + * http handler (js_content in the internal mirror location). The mirror + * subrequest runs in parallel with proxy_pass; awaiting the fetch keeps the + * subrequest — never the client response — alive until the report lands. + */ +async function http_record(r) { + try { + const id = r.variables.osaas_service_id; + if (id && ngx.shared.osaas_http.add(id, '1')) { + const reply = await report(r.variables.osaas_manager_url, r.variables.osaas_api_key, id); + if (reply.status !== 204) { + r.warn('osaas accounting: manager returned ' + reply.status + ' for service ' + id); + } + } + } catch (e) { + r.warn('osaas accounting: ' + e.message); + } + r.return(204); +} + +/** + * stream handler (js_access). Reports go via the localhost http relay (a unix + * socket) — Debian's njs stream module has no fetch-TLS — and the fetch is + * deliberately not awaited: the connection proceeds immediately. The explicit + * Host is required for the socket fetch (see report()). + */ +function stream_record(s) { + try { + const id = s.variables.osaas_service_id; + if (id && ngx.shared.osaas_stream.add(id, '1')) { + report(s.variables.osaas_relay_url, '', id, 'localhost') + .then((reply) => { + if (reply.status !== 204) { + s.warn('osaas accounting: relay returned ' + reply.status + ' for service ' + id); + } + }) + .catch((e) => { + s.warn('osaas accounting: ' + e.message); + }); + } + } catch (e) { + s.warn('osaas accounting: ' + e.message); + } + s.allow(); +} + +/** + * http relay for stream-context reports (js_content on the localhost-only + * relay server). Debian's njs stream module is built without NGX_STREAM_SSL, + * so stream_record cannot fetch an https manager directly; it POSTs to this + * relay over plain local http and the http js VM — full fetch-TLS — forwards + * to the manager. The path shape is validated so the relay can never be used + * to reach any other manager endpoint with the embedded credential. + */ +async function relay(r) { + try { + const m = r.uri.match(/^\/api\/v1\/services\/(\d+)\/last-access$/); + if (!m) { + r.return(404); + return; + } + const reply = await report(r.variables.osaas_manager_url, r.variables.osaas_api_key, m[1]); + r.return(reply.status); + return; + } catch (e) { + r.warn('osaas accounting relay: ' + e.message); + } + r.return(502); +} + +export default { http_record, stream_record, relay }; diff --git a/agent/package.json b/agent/package.json index 22ed975d..dbfc21b2 100644 --- a/agent/package.json +++ b/agent/package.json @@ -6,7 +6,8 @@ "license": "Apache-2.0", "scripts": { "build": "tsc", - "start": "node dist/index.js" + "start": "node dist/index.js", + "test": "node --test \"test/**/*.test.js\"" }, "dependencies": { "@particle/dbus-next": "^0.11.4", diff --git a/agent/src/apply.ts b/agent/src/apply.ts index a83c67ef..0efe413b 100644 --- a/agent/src/apply.ts +++ b/agent/src/apply.ts @@ -12,6 +12,7 @@ import { execFileSync } from 'child_process'; import ejs from 'ejs'; import { reloadOrRestartService, restartService, sighupService } from './system'; import { log, commandOutput } from './log'; +import type { AgentConfig } from './config'; import type { ApplyResult, SiteConfig } from './types'; const TEMPLATES_DIR = path.join(__dirname, '..', 'templates'); @@ -19,6 +20,8 @@ const TEMPLATES_DIR = path.join(__dirname, '..', 'templates'); interface RenderedFile { dest: string; content: string; + /** File mode for atomic writes (e.g. 0o600 for configs holding secrets). */ + mode?: number; } export interface ManagedService { @@ -26,7 +29,7 @@ export interface ManagedService { unit: string; /** Render all managed files. Returns null when there is nothing to manage * yet (e.g. dnsmasq before the site exists). */ - render(config: SiteConfig): Promise; + render(config: SiteConfig, agent: AgentConfig): Promise; /** Command that validates the staged config before it is kept. */ test?: string[]; /** Reload/restart after a successful apply. */ @@ -48,10 +51,19 @@ function run(cmd: string[]): string { export const services: ManagedService[] = [ { unit: 'nginx', - async render(config) { + async render(config, agent) { return [{ dest: '/etc/nginx/nginx.conf', - content: await renderTemplate('nginx.conf.ejs', config.nginx), + content: await renderTemplate('nginx.conf.ejs', { + ...config.nginx, + accounting: { + managerUrl: agent.managerUrl, + apiKey: agent.apiKey ?? '', + }, + }), + // The rendered config embeds the manager API key: root-only. nginx's + // master process reads the config as root before forking workers. + mode: 0o600, }]; }, test: ['nginx', '-t'], @@ -100,15 +112,19 @@ function readIfExists(file: string): string | null { // Write via temp file + rename so a crash mid-write can never leave a // truncated config on disk. -function writeFileAtomic(dest: string, content: string): void { +function writeFileAtomic(dest: string, content: string, mode?: number): void { const tmp = `${dest}.tmp-${process.pid}`; - fs.writeFileSync(tmp, content); + fs.writeFileSync(tmp, content, mode !== undefined ? { mode } : {}); fs.renameSync(tmp, dest); } -export async function applyService(svc: ManagedService, config: SiteConfig): Promise { +export async function applyService( + svc: ManagedService, + config: SiteConfig, + agent: AgentConfig, +): Promise { log.debug(`${svc.unit}: rendering config`); - const files = await svc.render(config); + const files = await svc.render(config, agent); if (!files) { log.debug(`${svc.unit}: nothing to manage yet, skipping`); return 'success'; @@ -123,17 +139,18 @@ export async function applyService(svc: ManagedService, config: SiteConfig): Pro log.info(`${svc.unit}: ${changed.length} file(s) changed, applying: ${changed.join(', ')}`); + const modeByDest = new Map(files.map((f) => [f.dest, f.mode])); // Stage the new files (previous contents kept in memory for rollback). for (const f of files) { fs.mkdirSync(path.dirname(f.dest), { recursive: true }); - writeFileAtomic(f.dest, f.content); + writeFileAtomic(f.dest, f.content, f.mode); log.debug(`${svc.unit}: wrote ${f.dest}`); } const rollback = () => { for (const [dest, prev] of current) { if (prev === null) fs.rmSync(dest, { force: true }); - else writeFileAtomic(dest, prev); + else writeFileAtomic(dest, prev, modeByDest.get(dest)); } log.debug(`${svc.unit}: rolled back to previous config`); }; diff --git a/agent/src/index.ts b/agent/src/index.ts index 071e6faa..d8340758 100644 --- a/agent/src/index.ts +++ b/agent/src/index.ts @@ -56,7 +56,7 @@ async function main(): Promise { log.info(`check-in: new config received (etag=${result.etag ?? '(none)'}), applying`); for (const svc of services) { - state.lastApply[svc.unit] = await applyService(svc, result.config); + state.lastApply[svc.unit] = await applyService(svc, result.config, cfg); } // The ETag is saved even after a failed apply: a rejected config won't diff --git a/agent/src/types.ts b/agent/src/types.ts index 4918e55d..b6775d3f 100644 --- a/agent/src/types.ts +++ b/agent/src/types.ts @@ -48,6 +48,8 @@ export interface SiteInfo { } export interface HttpService { + /** Manager Services.id — reported back by the accounting module. */ + id: number; internalPort: number; container: { ipv4Address: string }; externalHostname: string; @@ -57,6 +59,8 @@ export interface HttpService { } export interface StreamService { + /** Manager Services.id — reported back by the accounting module. */ + id: number; internalPort: number; container: { ipv4Address: string }; externalPort: number; diff --git a/agent/templates/dnsmasq/conf.ejs b/agent/templates/dnsmasq/conf.ejs index 1c8f90a2..1893a8a3 100644 --- a/agent/templates/dnsmasq/conf.ejs +++ b/agent/templates/dnsmasq/conf.ejs @@ -5,6 +5,13 @@ dhcp-authoritative domain-needed bogus-priv +# Resolve localhost to loopback (RFC 6761). no-hosts above makes dnsmasq +# ignore /etc/hosts, so without this "localhost" does not resolve — which +# breaks nginx's resolver-based lookups (e.g. the njs accounting module's +# ngx.fetch to the manager's own http://localhost:3000). Covers *.localhost. +address=/localhost/127.0.0.1 +address=/localhost/::1 + # internal domain for the site domain=<%= site.internalDomain %> diff --git a/agent/templates/nginx.conf.ejs b/agent/templates/nginx.conf.ejs index c57395a5..16fae25e 100644 --- a/agent/templates/nginx.conf.ejs +++ b/agent/templates/nginx.conf.ejs @@ -43,6 +43,26 @@ http { proxy_cache_path /var/cache/nginx/auth_cache levels=1:2 keys_zone=auth_cache:1m max_size=10m inactive=5m; + <%_ /* + Service last-access accounting (docs/superpowers/specs/ + 2026-08-11-service-last-access-accounting-design.md). The static njs + module claims a 10-minute window per service in the shared dict (entries + expire via timeout=; the shm zone survives reloads) and reports the first + access of each window to the manager from a parallel mirror subrequest. + resolver / js_fetch_timeout / js_fetch_trusted_certificate are set here at + http{} level so they inherit into both the mirror location and the relay + server below — no per-location copies needed. + */ -%> + js_import accounting from /opt/opensource-server/agent/njs/accounting.js; + js_shared_dict_zone zone=osaas_http:256k timeout=10m evict; + js_var $osaas_manager_url "<%= accounting.managerUrl %>"; + js_var $osaas_api_key "<%= accounting.apiKey %>"; + <%_ /* ngx.fetch needs a resolver for the manager hostname (dnsmasq runs on + every agent host) and the CA bundle for the https manager URL. */ -%> + resolver 127.0.0.1; + js_fetch_timeout 5s; + js_fetch_trusted_certificate /etc/ssl/certs/ca-certificates.crt; + <%_ /* Upload throughput over HTTP/2 and HTTP/3. The small defaults (64k body preread / h3 stream buffer, 256k per-worker h2 recv buffer) cap how much @@ -93,6 +113,24 @@ http { location /504.html { } location /auth-unavailable.html { } } + + <%_ /* + Localhost-only relay for stream-context accounting reports: Debian's njs + stream module lacks fetch-TLS (built without NGX_STREAM_SSL), so stream + servers POST plain-http to this unix socket and this http-context handler + forwards to the manager over https with the credential. A unix socket + (rather than a TCP port) keeps the relay off the network entirely. + resolver / js_fetch_timeout / js_fetch_trusted_certificate are inherited + from the http{} block above. + */ -%> + server { + listen unix:/run/nginx-osaas-relay.sock; + access_log off; + + location / { + js_content accounting.relay; + } + } server { listen 80 default_server; @@ -294,6 +332,13 @@ http { <%_ } else { _%> <%_ /* Proxy settings */ -%> location / { + <%_ /* Last-access accounting: runs in a parallel mirror subrequest, + after the auth gate. mirror_request_body off is REQUIRED to keep + uploads unbuffered (see #395). */ -%> + set $osaas_service_id "<%= service.id %>"; + mirror /_osaas_accounting; + mirror_request_body off; + <%_ if (authRequired && authServer) { _%> auth_request /oauth2/auth; @@ -342,6 +387,13 @@ http { proxy_buffers 4 32k; proxy_busy_buffers_size 64k; } + + location = /_osaas_accounting { + internal; + <%_ /* resolver / js_fetch_timeout / js_fetch_trusted_certificate are + inherited from the http{} block. */ -%> + js_content accounting.http_record; + } <%_ } _%> } <%_ }) _%> @@ -446,9 +498,27 @@ stream { access_log /var/log/nginx/stream-access.log main; + <%_ /* + Service last-access accounting, stream flavor (dicts are per-context, so + the stream side gets its own zone). js_access claims the window and + fires the report without awaiting it — connection setup never waits. + Debian's libnginx-mod-stream-js is built without NGX_STREAM_SSL, so + stream-side ngx.fetch has no TLS: reports hop through the localhost-only + http relay unix socket (see the http context), whose handler forwards to + the manager over https with the credential. The relay URL is the njs + unix-socket form — the trailing ':' separates the socket path from the + request URI that report() appends. + */ -%> + js_import accounting from /opt/opensource-server/agent/njs/accounting.js; + js_shared_dict_zone zone=osaas_stream:256k timeout=10m evict; + js_var $osaas_relay_url "http://unix:/run/nginx-osaas-relay.sock:"; + js_fetch_timeout 5s; + <%_ streamServices.forEach((service, index) => { _%> server { listen <%= service.externalPort %><%= service.protocol === 'udp' ? ' udp' : '' %>; + js_var $osaas_service_id "<%= service.id %>"; + js_access accounting.stream_record; proxy_pass <%= service.container.ipv4Address %>:<%= service.internalPort %>; } <%_ }) _%> diff --git a/agent/test/dnsmasq-template.test.js b/agent/test/dnsmasq-template.test.js new file mode 100644 index 00000000..cd21b7f7 --- /dev/null +++ b/agent/test/dnsmasq-template.test.js @@ -0,0 +1,41 @@ +/** + * dnsmasq conf.ejs render test (node --test, no extra deps). + * + * Pins that dnsmasq resolves `localhost` to loopback. The config uses + * `no-hosts`, so dnsmasq ignores /etc/hosts and would otherwise fail to + * resolve `localhost` — which breaks nginx's resolver-based lookups, e.g. + * the njs accounting module's ngx.fetch to the manager's own + * http://localhost:3000 (observed as: `"localhost" could not be resolved`). + */ + +const test = require('node:test'); +const assert = require('node:assert'); +const path = require('path'); +const ejs = require('ejs'); + +const TEMPLATE = path.join(__dirname, '..', 'templates', 'dnsmasq', 'conf.ejs'); + +const site = { + internalDomain: 'site.example', + dhcpRange: '10.254.1.100,10.254.1.200', + subnetMask: '255.255.255.0', + gateway: '10.254.1.1', + dnsForwarders: '1.1.1.1', + nodes: [], +}; + +function render() { + return ejs.renderFile(TEMPLATE, { site }); +} + +test('dnsmasq resolves localhost to loopback (v4 and v6)', async () => { + const conf = await render(); + assert.match(conf, /^address=\/localhost\/127\.0\.0\.1$/m); + assert.match(conf, /^address=\/localhost\/::1$/m); +}); + +test('the localhost override coexists with no-hosts', async () => { + const conf = await render(); + // no-hosts is why the explicit address= is needed; both must be present. + assert.match(conf, /^no-hosts$/m); +}); diff --git a/agent/test/nginx-template.test.js b/agent/test/nginx-template.test.js new file mode 100644 index 00000000..36f6a31a --- /dev/null +++ b/agent/test/nginx-template.test.js @@ -0,0 +1,106 @@ +/** + * nginx.conf.ejs render tests (node --test, no extra deps). Pins the + * last-access accounting hooks: module/zone/vars in the http context, + * per-service mirror + internal location, and their absence from the + * default/landing/wildcard servers. + */ + +const test = require('node:test'); +const assert = require('node:assert'); +const path = require('path'); +const ejs = require('ejs'); + +const TEMPLATE = path.join(__dirname, '..', 'templates', 'nginx.conf.ejs'); + +const accounting = { managerUrl: 'https://manager.example.com', apiKey: 'test-key-123' }; + +function render(data) { + return ejs.renderFile(TEMPLATE, { + httpServices: [], + streamServices: [], + externalDomains: [], + accounting, + ...data, + }); +} + +const httpService = { + id: 42, + internalPort: 3000, + container: { ipv4Address: '10.254.1.5' }, + externalHostname: 'myapp', + backendProtocol: 'http', + authRequired: false, + externalDomain: { name: 'example.com', authServer: null }, +}; + +test('http context loads the accounting module, dict, and vars', async () => { + const conf = await render({}); + assert.match(conf, /js_import accounting from \/opt\/opensource-server\/agent\/njs\/accounting\.js;/); + assert.match(conf, /js_shared_dict_zone zone=osaas_http:256k timeout=10m evict;/); + assert.match(conf, /js_var \$osaas_manager_url "https:\/\/manager\.example\.com";/); + assert.match(conf, /js_var \$osaas_api_key "test-key-123";/); + // fetch settings are hoisted to http{} level so the mirror location and the + // relay server both inherit them — one copy each, not per-location. + assert.strictEqual(conf.match(/js_fetch_trusted_certificate/g).length, 1); + assert.strictEqual((conf.match(/js_content accounting\.relay;/g) || []).length, 1); +}); + +test('http service location records last-access via a parallel mirror', async () => { + const conf = await render({ httpServices: [httpService] }); + assert.match(conf, /set \$osaas_service_id "42";/); + assert.match(conf, /mirror \/_osaas_accounting;/); + assert.match(conf, /mirror_request_body off;/); + assert.match(conf, /location = \/_osaas_accounting/); + assert.match(conf, /js_content accounting\.http_record;/); +}); + +test('auth-required service without an auth server gets no accounting hooks', async () => { + const conf = await render({ + httpServices: [{ ...httpService, authRequired: true }], + }); + // location / returns 503 — nothing is proxied, nothing is recorded. + assert.doesNotMatch(conf, /osaas_service_id/); + assert.doesNotMatch(conf, /mirror \//); +}); + +test('default, wildcard, and landing servers get no accounting hooks', async () => { + const conf = await render({ externalDomains: [{ name: 'example.com' }] }); + assert.doesNotMatch(conf, /osaas_service_id/); + assert.doesNotMatch(conf, /mirror \//); +}); + +const streamService = { + id: 77, + internalPort: 22, + container: { ipv4Address: '10.254.1.6' }, + externalPort: 30022, + protocol: 'tcp', +}; + +test('stream context loads the accounting module, dict, and relay url', async () => { + const conf = await render({ streamServices: [streamService] }); + assert.match(conf, /js_shared_dict_zone zone=osaas_stream:256k timeout=10m evict;/); + // js_import appears twice: once in http, once in stream. + const imports = conf.match(/js_import accounting from \/opt\/opensource-server\/agent\/njs\/accounting\.js;/g); + assert.strictEqual(imports.length, 2); + // Streams report via the localhost relay (stream js has no fetch-TLS on + // Debian njs 0.8.9), so the manager URL and API key must NOT appear in the + // stream context: the key appears exactly once, as the http-context js_var. + assert.match(conf, /js_var \$osaas_relay_url "http:\/\/unix:\/run\/nginx-osaas-relay\.sock:";/); + assert.strictEqual(conf.match(/test-key-123/g).length, 1); +}); + +test('http context exposes the localhost relay for stream reports', async () => { + const conf = await render({}); + assert.match(conf, /listen unix:\/run\/nginx-osaas-relay\.sock;/); + assert.match(conf, /js_content accounting\.relay;/); +}); + +test('stream server records last-access at the access phase', async () => { + const conf = await render({ streamServices: [streamService] }); + assert.match(conf, /js_var \$osaas_service_id "77";/); + assert.match(conf, /js_access accounting\.stream_record;/); + assert.match(conf, /listen 30022;/); + assert.match(conf, /proxy_pass 10\.254\.1\.6:22;/); +}); diff --git a/create-a-container/client/src/components/containers/ContainersDataGrid.tsx b/create-a-container/client/src/components/containers/ContainersDataGrid.tsx index e91b316d..481d8df8 100644 --- a/create-a-container/client/src/components/containers/ContainersDataGrid.tsx +++ b/create-a-container/client/src/components/containers/ContainersDataGrid.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo } from 'react'; import { DataVisNitroGrid, DataVisNitroSource } from '@mieweb/ui/datavis'; import type { ColumnFilterConfig, TableColumn, TableRendererProps } from '@mieweb/datavis'; import { User } from 'lucide-react'; +import { formatRelativeTime } from '@/lib/formatRelativeTime'; import type { Container } from '@/lib/types'; import { HttpLinks } from './HttpLinks'; import { NodeLink } from './NodeLink'; @@ -29,6 +30,7 @@ const TYPE_INFO: { field: string; type: string }[] = [ { field: 'nodeName', type: 'string' }, { field: 'owner', type: 'string' }, { field: 'template', type: 'string' }, + { field: 'lastAccessedAt', type: 'string' }, ]; const asContainer = (row: Record) => row as unknown as Container; @@ -100,6 +102,13 @@ export function ContainersDataGrid({ return c.sshHost && c.sshPort ? `${c.sshHost}:${c.sshPort}` : ''; }, }, + { + field: 'lastAccessedAt', + header: 'Last Access', + sortable: true, + filterable: false, + getSearchText: (_v, row) => formatRelativeTime(asContainer(row).lastAccessedAt), + }, { field: 'actions', header: '', @@ -172,6 +181,12 @@ export function ContainersDataGrid({ /> ); + case 'lastAccessedAt': + return ( + + {formatRelativeTime(c.lastAccessedAt)} + + ); default: return value as React.ReactNode; } diff --git a/create-a-container/client/src/lib/formatRelativeTime.ts b/create-a-container/client/src/lib/formatRelativeTime.ts new file mode 100644 index 00000000..65564c80 --- /dev/null +++ b/create-a-container/client/src/lib/formatRelativeTime.ts @@ -0,0 +1,20 @@ +/** + * "42s ago" / "5m ago" / "3h ago" / locale datetime / "never". + * + * Pass `secondsSince` when the server computed the age (e.g. the agents + * endpoint's secondsSinceCheckin) so the judgment doesn't depend on the + * client clock; otherwise the age is derived from Date.now(), which is fine + * for display-only relative times. + */ +export function formatRelativeTime( + iso: string | null | undefined, + secondsSince?: number | null, +): string { + if (!iso) return 'never'; + const seconds = + secondsSince ?? Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000)); + if (seconds < 60) return `${seconds}s ago`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + return new Date(iso).toLocaleString(); +} diff --git a/create-a-container/client/src/lib/types.ts b/create-a-container/client/src/lib/types.ts index 963dbae5..dd1da301 100644 --- a/create-a-container/client/src/lib/types.ts +++ b/create-a-container/client/src/lib/types.ts @@ -142,6 +142,8 @@ export interface ContainerService { id: number; type: 'http' | 'transport' | 'dns'; internalPort: number; + /** Proxy-reported last access (ISO datetime); null when never accessed. */ + lastAccessedAt: string | null; httpService: ServiceHttp | null; transportService: ServiceTransport | null; dnsService: ServiceDns | null; @@ -165,6 +167,8 @@ export interface Container { sshPort: number | null; sshHost: string | null; httpEntries: { port: number; externalUrl: string | null }[]; + /** Max lastAccessedAt across services; null when never accessed. */ + lastAccessedAt: string | null; nodeName: string | null; nodeApiUrl: string | null; services: ContainerService[]; diff --git a/create-a-container/client/src/pages/agents/AgentsListPage.tsx b/create-a-container/client/src/pages/agents/AgentsListPage.tsx index 96bbfbb1..b7a04c01 100644 --- a/create-a-container/client/src/pages/agents/AgentsListPage.tsx +++ b/create-a-container/client/src/pages/agents/AgentsListPage.tsx @@ -13,21 +13,13 @@ import { } from '@mieweb/ui'; import { Radio } from 'lucide-react'; import { ApiError } from '@/lib/api'; +import { formatRelativeTime } from '@/lib/formatRelativeTime'; import { keys, queries } from '@/lib/queries'; import type { Agent } from '@/lib/types'; import { useDocumentTitle } from '@/lib/useDocumentTitle'; import { AgentServiceBadges } from './AgentServiceBadges'; import { OnlineBadge } from './OnlineBadge'; -function formatLastCheckin(agent: Agent): string { - const seconds = agent.secondsSinceCheckin; - if (seconds === null || !agent.lastCheckinAt) return 'never'; - if (seconds < 60) return `${seconds}s ago`; - if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; - if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; - return new Date(agent.lastCheckinAt).toLocaleString(); -} - export function AgentsListPage() { useDocumentTitle('Agents'); const { data, isLoading, error } = useQuery({ @@ -88,7 +80,7 @@ export function AgentsListPage() { - {formatLastCheckin(agent)} + {formatRelativeTime(agent.lastCheckinAt, agent.secondsSinceCheckin)} ))} diff --git a/create-a-container/middlewares/api.js b/create-a-container/middlewares/api.js index ee4e6205..76ceccd8 100644 --- a/create-a-container/middlewares/api.js +++ b/create-a-container/middlewares/api.js @@ -26,6 +26,25 @@ const { req.headers['x-csrf-token'] || (req.body && req.body._csrf), }); +// True when the request comes directly from localhost (and was not proxied +// on behalf of a remote client, per X-Real-IP / X-Forwarded-For). Used by the +// CSRF guard and the localhost-or-admin auth below to let the manager's own +// agent through without credentials during bootstrap. +function isLocalhostRequest(req) { + const isLocalhost = (ip) => + ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1' || ip === 'localhost'; + + const directIp = req.connection?.remoteAddress || req.socket?.remoteAddress || req.ip; + const realIp = req.get('X-Real-IP'); + // First hop of X-Forwarded-For is the original client (covers proxies that + // set it without also setting X-Real-IP). + const forwardedFor = (req.get('X-Forwarded-For') || '').split(',')[0].trim(); + + return isLocalhost(directIp) + && (!realIp || isLocalhost(realIp)) + && (!forwardedFor || isLocalhost(forwardedFor)); +} + // CSRF guard: enforce on state-changing methods. Only exempt requests that // are purely Bearer-authenticated (i.e., do NOT also carry a session cookie). // A session cookie is always sent by browsers, so a Bearer header alone @@ -38,13 +57,11 @@ function csrfGuard(req, res, next) { } // The manager's own agent checks in over localhost without credentials // during bootstrap (no site or API key exists yet), so it can carry neither - // a Bearer token nor a CSRF token. The agents router applies the same - // localhost bypass at the route level (checkinAuth); mirror it here so this - // app-level guard doesn't reject the credential-less check-in first. Remote - // clients are never localhost (isLocalhostRequest also rejects proxied - // requests via X-Real-IP / X-Forwarded-For). Required lazily to avoid a - // load-order cycle with middlewares/index. - const { isLocalhostRequest } = require('./index'); + // a Bearer token nor a CSRF token. The check-in and accounting routes apply + // the same localhost bypass at the route level (localhostOrAdmin); mirror it + // here so this app-level guard doesn't reject the credential-less check-in + // first. Remote clients are never localhost (isLocalhostRequest also rejects + // proxied requests via X-Real-IP / X-Forwarded-For). if (isLocalhostRequest(req)) return next(); const auth = req.get('Authorization') || ''; const hasBearer = auth.startsWith('Bearer '); @@ -93,6 +110,20 @@ function apiAdmin(req, res, next) { return res.status(403).json({ error: { code: 'forbidden', message: 'Admin access required' } }); } +// Trust model for machine-facing endpoints the site proxy/agent calls: the +// manager's own agent reaches them over localhost without credentials +// (bootstrap — no site or API key exists yet), while remote agents must +// present an admin API key (apiAuth + apiAdmin, so error responses follow the +// v1 JSON envelope). Shared by the agent check-in and the service-accounting +// routes. +function localhostOrAdmin(req, res, next) { + if (isLocalhostRequest(req)) return next(); + return apiAuth(req, res, (err) => { + if (err) return next(err); + return apiAdmin(req, res, next); + }); +} + // --- Helpers ------------------------------------------------------------------------------ function asyncHandler(fn) { return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); @@ -153,6 +184,8 @@ class ApiError extends Error { module.exports = { apiAuth, apiAdmin, + localhostOrAdmin, + isLocalhostRequest, csrfGuard, generateCsrfToken, asyncHandler, diff --git a/create-a-container/middlewares/index.js b/create-a-container/middlewares/index.js index b2f8654a..1bfe4540 100644 --- a/create-a-container/middlewares/index.js +++ b/create-a-container/middlewares/index.js @@ -73,35 +73,10 @@ function requireAdmin(req, res, next) { return res.status(403).send('Forbidden: Admin access required'); } -// True when the request comes directly from localhost (and was not proxied -// on behalf of a remote client, per X-Real-IP / X-Forwarded-For). -function isLocalhostRequest(req) { - const isLocalhost = (ip) => { - return ip === '127.0.0.1' || - ip === '::1' || - ip === '::ffff:127.0.0.1' || - ip === 'localhost'; - }; - - const directIp = req.connection?.remoteAddress || - req.socket?.remoteAddress || - req.ip; - - const realIp = req.get('X-Real-IP'); - // First hop of X-Forwarded-For is the original client (covers proxies that - // set it without also setting X-Real-IP). - const forwardedFor = (req.get('X-Forwarded-For') || '').split(',')[0].trim(); - - return isLocalhost(directIp) - && (!realIp || isLocalhost(realIp)) - && (!forwardedFor || isLocalhost(forwardedFor)); -} - const { setCurrentSite, loadSites } = require('./currentSite'); module.exports = { isApiRequest, - isLocalhostRequest, requireAuth, requireAdmin, setCurrentSite, diff --git a/create-a-container/migrations/20260731120000-create-notifications.js b/create-a-container/migrations/20260731120000-create-notifications.js index c79d09bc..3034a4e2 100644 --- a/create-a-container/migrations/20260731120000-create-notifications.js +++ b/create-a-container/migrations/20260731120000-create-notifications.js @@ -13,8 +13,12 @@ module.exports = { // Hypervisor node name the event originated on. node: { type: Sequelize.STRING(255), allowNull: true }, // Container id on the hypervisor (CTID/VMID). STRING to match - // Containers.containerId, which was widened to a string. - ctid: { type: Sequelize.STRING(255), allowNull: true }, + // Containers.containerId, which was widened to a string. The physical + // column is "containerId" (not "ctid"): Postgres reserves "ctid" as a + // system column on every table, so CREATE TABLE ... "ctid" fails with + // 42701. The model maps its `ctid` attribute onto this column via + // Sequelize's `field:` option, so the API/JSON field stays `ctid`. + containerId: { type: Sequelize.STRING(255), allowNull: true }, // Owning user (Users.uid). Drives per-user UI visibility. Resolved from // node+ctid at ingest time when the payload omits it. owner: { type: Sequelize.STRING(255), allowNull: true }, diff --git a/create-a-container/migrations/20260811000000-add-service-last-accessed-at.js b/create-a-container/migrations/20260811000000-add-service-last-accessed-at.js new file mode 100644 index 00000000..55f53db5 --- /dev/null +++ b/create-a-container/migrations/20260811000000-add-service-last-accessed-at.js @@ -0,0 +1,15 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('Services', 'lastAccessedAt', { + type: Sequelize.DATE, + allowNull: true, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('Services', 'lastAccessedAt'); + } +}; diff --git a/create-a-container/models/notification.js b/create-a-container/models/notification.js index f5f7d1be..8389fd67 100644 --- a/create-a-container/models/notification.js +++ b/create-a-container/models/notification.js @@ -29,7 +29,11 @@ module.exports = (sequelize, DataTypes) => { validate: { isIn: [SEVERITIES] }, }, node: { type: DataTypes.STRING(255), allowNull: true }, - ctid: { type: DataTypes.STRING(255), allowNull: true }, + // Physical column is "containerId": Postgres reserves "ctid" as a system + // column name, so the table cannot have a column literally named "ctid". + // The attribute stays `ctid` (API/JSON/query surface unchanged) and maps + // onto the containerId column via `field`. + ctid: { type: DataTypes.STRING(255), allowNull: true, field: 'containerId' }, owner: { type: DataTypes.STRING(255), allowNull: true }, // Free-form (node-side tools may emit new actions); bounded length only. action: { type: DataTypes.STRING(255), allowNull: true }, diff --git a/create-a-container/models/service.js b/create-a-container/models/service.js index bc6a828b..7caa8c5a 100644 --- a/create-a-container/models/service.js +++ b/create-a-container/models/service.js @@ -21,6 +21,12 @@ module.exports = (sequelize, DataTypes) => { internalPort: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false + }, + // Stamped by POST /api/v1/services/:id/last-access when the site proxy + // reports traffic (at most once per 10 minutes per service). + lastAccessedAt: { + type: DataTypes.DATE, + allowNull: true } }, { sequelize, diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index c88603a7..e5815231 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -169,6 +169,13 @@ components: properties: port: { type: integer } externalUrl: { type: string, nullable: true } + lastAccessedAt: + type: string + format: date-time + nullable: true + description: >- + Most recent proxy-reported access across the container's services + (max of the services' lastAccessedAt). Null when never accessed. nodeName: { type: string, nullable: true } nodeApiUrl: { type: string, nullable: true } services: @@ -181,6 +188,13 @@ components: id: { type: integer } type: { type: string, enum: [http, transport, dns] } internalPort: { type: integer } + lastAccessedAt: + type: string + format: date-time + nullable: true + description: >- + Set when the site proxy reports traffic to this service — at most + once per 10 minutes per service. Null when never accessed. httpService: type: object nullable: true @@ -1298,6 +1312,28 @@ paths: data: { $ref: '#/components/schemas/Notification' } '401': { description: Authentication required, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } '404': { description: Not found or not owned by the caller, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + /services/{id}/last-access: + post: + operationId: record_service_access + tags: [Agents] + summary: Record service access (proxy accounting) + description: | + Called by the site proxy (nginx accounting module) on the first + request/connection to a service after 10+ minutes of none. Sets the + service's lastAccessedAt to the current server time. Allowed from + localhost without credentials (manager bootstrap) or with an admin + API key. Exempt from the CSRF guard for Bearer/localhost callers. + parameters: + - in: path + name: id + required: true + schema: { type: integer } + responses: + '204': { description: Access recorded } + '400': { description: 'Non-numeric id (code: invalid_request)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '401': { description: 'Missing/invalid credentials from a non-localhost caller', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } /external-domains: get: diff --git a/create-a-container/resources/services/__tests__/services.api.test.js b/create-a-container/resources/services/__tests__/services.api.test.js new file mode 100644 index 00000000..8c51e756 --- /dev/null +++ b/create-a-container/resources/services/__tests__/services.api.test.js @@ -0,0 +1,141 @@ +/** + * POST /api/v1/services/:id/last-access — proxy accounting endpoint. + * Trust model mirrors the agent check-in (routers/api/v1/agents.js): + * localhost posts without credentials (the manager's own proxy), remote + * proxies authenticate with an admin API key. supertest connects from + * 127.0.0.1; an X-Forwarded-For with a public IP marks a request as remote. + */ + +const request = require('supertest'); +const { buildApp, bearer } = require('../../../tests/helpers/app'); +const { resetDb, closeDb, createUser, createApiKey } = require('../../../tests/helpers/db'); +const { Site, Node, Container, Service } = require('../../../models'); + +describe('POST /api/v1/services/:id/last-access', () => { + let app; + let service; + + beforeEach(async () => { + await resetDb(); + app = buildApp(); + const site = await Site.create({ name: 'test-site' }); + const node = await Node.create({ siteId: site.id, name: 'node1', nodeType: 'dummy' }); + const container = await Container.create({ + hostname: 'testct', + username: 'tester', + nodeId: node.id, + siteId: site.id, + }); + service = await Service.create({ containerId: container.id, type: 'http', internalPort: 80 }); + }); + + afterAll(async () => { + await closeDb(); + }); + + test('localhost without credentials records access', async () => { + const before = Date.now(); + const res = await request(app).post(`/api/v1/services/${service.id}/last-access`); + expect(res.status).toBe(204); + + await service.reload(); + expect(service.lastAccessedAt).not.toBeNull(); + const stamped = new Date(service.lastAccessedAt).getTime(); + expect(stamped).toBeGreaterThanOrEqual(before - 1000); + expect(stamped).toBeLessThanOrEqual(Date.now() + 1000); + }); + + test('remote without credentials is rejected', async () => { + const res = await request(app) + .post(`/api/v1/services/${service.id}/last-access`) + .set('X-Forwarded-For', '203.0.113.7'); + expect([401, 403]).toContain(res.status); + + await service.reload(); + expect(service.lastAccessedAt).toBeNull(); + }); + + test('remote with admin API key records access', async () => { + const admin = await createUser({ admin: true }); + const { plainKey } = await createApiKey(admin); + const res = await request(app) + .post(`/api/v1/services/${service.id}/last-access`) + .set('X-Forwarded-For', '203.0.113.7') + .set(...bearer(plainKey)); + expect(res.status).toBe(204); + + await service.reload(); + expect(service.lastAccessedAt).not.toBeNull(); + }); + + test('remote with non-admin API key is 403', async () => { + // First user after resetDb is auto-promoted to sysadmins; burn one. + await createUser(); + const plain = await createUser(); + const { plainKey } = await createApiKey(plain); + const res = await request(app) + .post(`/api/v1/services/${service.id}/last-access`) + .set('X-Forwarded-For', '203.0.113.7') + .set(...bearer(plainKey)); + expect(res.status).toBe(403); + }); + + test('unknown service id is 404', async () => { + const res = await request(app).post('/api/v1/services/999999/last-access'); + expect(res.status).toBe(404); + expect(res.body.error.code).toBe('not_found'); + }); + + test('non-numeric id is 400', async () => { + const res = await request(app).post('/api/v1/services/abc/last-access'); + expect(res.status).toBe(400); + }); + + // The route is machine-facing (localhost or Bearer), but /api/v1 is behind + // csrfGuard, which enforces CSRF only for session-cookie requests that carry + // no Bearer. Pin that a remote session-authenticated request is rejected + // without a CSRF token and succeeds with one, so the Bearer/localhost + // exemptions can't silently regress into a CSRF hole. + describe('session-authenticated (CSRF-guarded) access', () => { + // A supertest agent persists cookies across requests, so the session + // established by /csrf-token and /auth/login carries into the accounting + // POST. X-Forwarded-For marks the request as remote so the localhost + // bypass does not apply. + async function loginAgent() { + const admin = await createUser({ admin: true }); + const agent = request.agent(app); + const tokenRes = await agent.get('/api/v1/csrf-token'); + const csrfToken = tokenRes.body.data.csrfToken; + const loginRes = await agent + .post('/api/v1/auth/login') + .set('X-CSRF-Token', csrfToken) + .send({ username: admin.uid, password: 'correct horse battery staple' }); + expect(loginRes.status).toBe(200); + return { agent, csrfToken }; + } + + test('remote session request without a CSRF token is 403', async () => { + const { agent } = await loginAgent(); + const res = await agent + .post(`/api/v1/services/${service.id}/last-access`) + .set('X-Forwarded-For', '203.0.113.7'); + expect(res.status).toBe(403); + expect(res.body.error.code).toBe('csrf_invalid'); + + await service.reload(); + expect(service.lastAccessedAt).toBeNull(); + }); + + test('remote session request with a valid CSRF token records access', async () => { + const { agent, csrfToken } = await loginAgent(); + const res = await agent + .post(`/api/v1/services/${service.id}/last-access`) + .set('X-Forwarded-For', '203.0.113.7') + .set('X-CSRF-Token', csrfToken); + expect(res.status).toBe(204); + + await service.reload(); + expect(service.lastAccessedAt).not.toBeNull(); + }); + }); +}); diff --git a/create-a-container/resources/services/controller.js b/create-a-container/resources/services/controller.js new file mode 100644 index 00000000..60a4bc79 --- /dev/null +++ b/create-a-container/resources/services/controller.js @@ -0,0 +1,9 @@ +const svc = require('./service'); +const { asyncHandler, noContent } = require('../../middlewares/api'); + +const recordAccess = asyncHandler(async (req, res) => { + await svc.recordAccess(req.validated.params.id); + return noContent(res); +}); + +module.exports = { recordAccess }; diff --git a/create-a-container/resources/services/repository.js b/create-a-container/resources/services/repository.js new file mode 100644 index 00000000..b9ebc76a --- /dev/null +++ b/create-a-container/resources/services/repository.js @@ -0,0 +1,17 @@ +const { Service } = require('../../models'); + +/** + * Stamp lastAccessedAt = now for one service. A single UPDATE statement — + * no SELECT, no row instantiation (the endpoint is called by nginx on the + * hot-ish path and must stay cheap). Returns the affected row count + * (0 = unknown id). + */ +async function recordAccess(id) { + const [count] = await Service.update( + { lastAccessedAt: new Date() }, + { where: { id } }, + ); + return count; +} + +module.exports = { recordAccess }; diff --git a/create-a-container/resources/services/router.js b/create-a-container/resources/services/router.js new file mode 100644 index 00000000..044ce4b2 --- /dev/null +++ b/create-a-container/resources/services/router.js @@ -0,0 +1,23 @@ +/** + * /api/v1/services — machine-facing service accounting. + * + * POST /:id/last-access proxy accounting: the site's nginx reports the + * first access to a service after 10+ minutes of + * none; sets lastAccessedAt to the current server + * time (agent clocks are never trusted). + */ + +const express = require('express'); +const { localhostOrAdmin } = require('../../middlewares/api'); +const { validate } = require('../../middlewares/validate'); +const { idParam } = require('./validator'); +const ctrl = require('./controller'); + +const router = express.Router(); + +// Trust model: the manager's own proxy reports over localhost without +// credentials (bootstrap); remote proxies authenticate with an admin API key. +// Shared with the agent check-in route (see middlewares/api localhostOrAdmin). +router.post('/:id/last-access', localhostOrAdmin, validate({ params: idParam }), ctrl.recordAccess); + +module.exports = router; diff --git a/create-a-container/resources/services/service.js b/create-a-container/resources/services/service.js new file mode 100644 index 00000000..2838cf72 --- /dev/null +++ b/create-a-container/resources/services/service.js @@ -0,0 +1,9 @@ +const repo = require('./repository'); +const { ApiError } = require('../../middlewares/api'); + +async function recordAccess(id) { + const count = await repo.recordAccess(id); + if (count === 0) throw new ApiError(404, 'not_found', 'Service not found'); +} + +module.exports = { recordAccess }; diff --git a/create-a-container/resources/services/validator.js b/create-a-container/resources/services/validator.js new file mode 100644 index 00000000..9561f021 --- /dev/null +++ b/create-a-container/resources/services/validator.js @@ -0,0 +1,8 @@ +const { z } = require('zod'); + +// Service ids are integer PKs; coerce because path params arrive as strings. +const idParam = z.object({ + id: z.coerce.number().int().positive(), +}); + +module.exports = { idParam }; diff --git a/create-a-container/routers/api/v1/__tests__/agents.checkin.test.js b/create-a-container/routers/api/v1/__tests__/agents.checkin.test.js index 8c95751e..ba2b98df 100644 --- a/create-a-container/routers/api/v1/__tests__/agents.checkin.test.js +++ b/create-a-container/routers/api/v1/__tests__/agents.checkin.test.js @@ -2,7 +2,7 @@ * Agent check-in auth — the manager's own agent bootstraps over localhost with * no site row and no API key, so its POST /api/v1/agents carries neither a * Bearer token nor a CSRF token. Two guards must let it through: the app-level - * csrfGuard (app.js) and the route-level checkinAuth (agents.js). This pins + * csrfGuard (app.js) and the route-level localhostOrAdmin (agents.js). This pins * that the credential-less localhost check-in is NOT rejected with 403, while * a non-localhost (proxied) credential-less check-in still is. * diff --git a/create-a-container/routers/api/v1/__tests__/containers.serialize.test.js b/create-a-container/routers/api/v1/__tests__/containers.serialize.test.js new file mode 100644 index 00000000..ccf44f5e --- /dev/null +++ b/create-a-container/routers/api/v1/__tests__/containers.serialize.test.js @@ -0,0 +1,73 @@ +/** + * serializeContainer — per-service lastAccessedAt plus the container-level + * rollup (max across services; null when never accessed). Exercised directly + * against stub objects so no Proxmox/status machinery is involved. + */ + +const { serializeContainer } = require('../containers'); +const { closeDb } = require('../../../../tests/helpers/db'); + +afterAll(async () => { + await closeDb(); +}); + +function stubService(overrides = {}) { + return { + id: 10, + type: 'http', + internalPort: 80, + httpService: null, + transportService: null, + dnsService: null, + lastAccessedAt: null, + ...overrides, + }; +} + +function stubContainer(services) { + return { + id: 1, + containerId: '101', + hostname: 'testct', + username: 'alice', + collaboratorNames: () => [], + ipv4Address: '10.254.1.5', + macAddress: null, + template: null, + creationJobId: null, + entrypoint: null, + environmentVars: null, + nvidiaRequested: false, + node: null, + createdAt: new Date('2026-08-01T00:00:00Z'), + services, + }; +} + +const site = { externalIp: null }; + +test('services carry lastAccessedAt and the container rolls up the max', () => { + const older = new Date('2026-08-11T09:00:00Z'); + const newer = new Date('2026-08-11T10:30:00Z'); + const out = serializeContainer( + stubContainer([ + stubService({ id: 10, lastAccessedAt: older }), + stubService({ id: 11, internalPort: 8080, lastAccessedAt: newer }), + stubService({ id: 12, internalPort: 9090, lastAccessedAt: null }), + ]), + site, + 'running', + ); + expect(out.services.map((s) => s.lastAccessedAt)).toEqual([older, newer, null]); + expect(out.lastAccessedAt).toEqual(newer); +}); + +test('container lastAccessedAt is null when no service was ever accessed', () => { + const out = serializeContainer(stubContainer([stubService()]), site, 'running'); + expect(out.lastAccessedAt).toBeNull(); +}); + +test('container lastAccessedAt is null with no services', () => { + const out = serializeContainer(stubContainer([]), site, 'running'); + expect(out.lastAccessedAt).toBeNull(); +}); diff --git a/create-a-container/routers/api/v1/agents.js b/create-a-container/routers/api/v1/agents.js index 84b6b45a..d9a014ee 100644 --- a/create-a-container/routers/api/v1/agents.js +++ b/create-a-container/routers/api/v1/agents.js @@ -9,25 +9,12 @@ const express = require('express'); const { Agent, Site } = require('../../../models'); -const { isLocalhostRequest } = require('../../../middlewares'); -const { apiAuth, apiAdmin, asyncHandler, ok, fail } = require('../../../middlewares/api'); +const { apiAuth, apiAdmin, localhostOrAdmin, asyncHandler, ok, fail } = require('../../../middlewares/api'); const { buildAgentConfig, computeConfigEtag } = require('../../../utils/agent-config'); const router = express.Router(); -// Check-in auth: the manager's own agent checks in over localhost without -// credentials (bootstrap: no site, no API key exist yet); remote agents -// authenticate with an admin API key via apiAuth/apiAdmin so error -// responses follow the v1 JSON envelope. -function checkinAuth(req, res, next) { - if (isLocalhostRequest(req)) return next(); - return apiAuth(req, res, (err) => { - if (err) return next(err); - return apiAdmin(req, res, next); - }); -} - -router.post('/', checkinAuth, asyncHandler(async (req, res) => { +router.post('/', localhostOrAdmin, asyncHandler(async (req, res) => { const { siteId, hostname, ipv4Address, services } = req.body || {}; const parsedSiteId = typeof siteId === 'number' ? siteId : Number(siteId); if (!Number.isInteger(parsedSiteId) || !hostname || typeof hostname !== 'string') { diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js index 8d1d676b..1f7cfb0f 100644 --- a/create-a-container/routers/api/v1/containers.js +++ b/create-a-container/routers/api/v1/containers.js @@ -115,6 +115,12 @@ function serializeContainer(c, site, status) { return { port: s.internalPort, externalUrl: host ? `https://${host}` : null }; }); const primaryHttp = httpEntries[0] || null; + // Most recent proxy-reported access across this container's services + // (see POST /api/v1/services/:id/last-access). Null = never accessed. + const lastAccessedAt = services.reduce( + (max, s) => (s.lastAccessedAt && (!max || s.lastAccessedAt > max) ? s.lastAccessedAt : max), + null, + ); return { id: c.id, containerId: c.containerId, @@ -139,12 +145,14 @@ function serializeContainer(c, site, status) { sshPort: ssh?.transportService?.externalPort || null, sshHost: primaryHttp?.externalUrl ? new URL(primaryHttp.externalUrl).hostname : site?.externalIp, httpEntries, + lastAccessedAt, nodeName: c.node ? c.node.name : null, nodeApiUrl: c.node ? c.node.apiUrl : null, services: services.map((s) => ({ id: s.id, type: s.type, internalPort: s.internalPort, + lastAccessedAt: s.lastAccessedAt ?? null, httpService: s.httpService ? { id: s.httpService.id, @@ -913,3 +921,5 @@ router.delete( ); module.exports = router; +// Exported for unit tests (containers.serialize.test.js). +module.exports.serializeContainer = serializeContainer; diff --git a/create-a-container/routers/api/v1/index.js b/create-a-container/routers/api/v1/index.js index eb83aa46..3e45093a 100644 --- a/create-a-container/routers/api/v1/index.js +++ b/create-a-container/routers/api/v1/index.js @@ -95,6 +95,7 @@ router.use('/settings', require('./settings')); router.use('/jobs', require('./jobs')); router.use('/resource-requests', require('./resource-requests')); router.use('/notifications', require('../../../resources/notifications/router')); +router.use('/services', require('../../../resources/services/router')); // Final error handler — must come after all routes router.use(jsonErrorHandler); diff --git a/create-a-container/utils/__tests__/agent-config.test.js b/create-a-container/utils/__tests__/agent-config.test.js new file mode 100644 index 00000000..8cc2f3f7 --- /dev/null +++ b/create-a-container/utils/__tests__/agent-config.test.js @@ -0,0 +1,59 @@ +/** + * buildAgentConfig — the snapshot must carry Services.id on every http and + * stream entry (the nginx accounting module reports last-access by service + * id), and stay deterministic so the strong ETag is stable. + */ + +const { resetDb, closeDb } = require('../../tests/helpers/db'); +const { + Site, Node, Container, Service, HTTPService, TransportService, ExternalDomain, +} = require('../../models'); +const { buildAgentConfig, computeConfigEtag } = require('../agent-config'); + +describe('buildAgentConfig service ids', () => { + let site; + let httpSvc; + let streamSvc; + + beforeEach(async () => { + await resetDb(); + site = await Site.create({ name: 'test-site' }); + const node = await Node.create({ siteId: site.id, name: 'node1', nodeType: 'dummy' }); + const container = await Container.create({ + hostname: 'testct', + username: 'tester', + nodeId: node.id, + siteId: site.id, + ipv4Address: '10.254.1.5', + }); + const domain = await ExternalDomain.create({ name: 'example.com' }); + + httpSvc = await Service.create({ containerId: container.id, type: 'http', internalPort: 3000 }); + await HTTPService.create({ + serviceId: httpSvc.id, + externalHostname: 'myapp', + externalDomainId: domain.id, + }); + + streamSvc = await Service.create({ containerId: container.id, type: 'transport', internalPort: 22 }); + await TransportService.create({ serviceId: streamSvc.id, protocol: 'tcp', externalPort: 30022 }); + }); + + afterAll(async () => { + await closeDb(); + }); + + test('http and stream entries carry the Services.id', async () => { + const config = await buildAgentConfig(site.id); + expect(config.nginx.httpServices).toHaveLength(1); + expect(config.nginx.httpServices[0].id).toBe(httpSvc.id); + expect(config.nginx.streamServices).toHaveLength(1); + expect(config.nginx.streamServices[0].id).toBe(streamSvc.id); + }); + + test('snapshot stays deterministic (stable ETag)', async () => { + const etag1 = computeConfigEtag(await buildAgentConfig(site.id)); + const etag2 = computeConfigEtag(await buildAgentConfig(site.id)); + expect(etag1).toBe(etag2); + }); +}); diff --git a/create-a-container/utils/agent-config.js b/create-a-container/utils/agent-config.js index 781d16f5..7a09c041 100644 --- a/create-a-container/utils/agent-config.js +++ b/create-a-container/utils/agent-config.js @@ -69,6 +69,7 @@ async function buildAgentConfig(siteId) { for (const container of node.containers || []) { for (const service of container.services || []) { const base = { + id: service.id, internalPort: service.internalPort, container: { ipv4Address: container.ipv4Address }, };