From 97cc0a5b8c4628340714ca9dc99b163247d4ecf0 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 15 Jun 2026 08:17:42 -0400 Subject: [PATCH 01/11] =?UTF-8?q?=F0=9F=94=84=20refactor(edge):=20rename?= =?UTF-8?q?=20experimental=20edge-agent=20feature=20lookout=20=E2=86=92=20?= =?UTF-8?q?portwing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - env flag DD_EXPERIMENTAL_LOOKOUT → DD_EXPERIMENTAL_PORTWING (default off, zero footprint when unset) - WS endpoint /api/v1/lookout/ws → /api/v1/portwing/ws; subprotocol lookout/1.0 → portwing/1.0 - key registry /api/v1/lookout/keys → /api/v1/portwing/keys - rename app/api/lookout*.ts → portwing*.ts, openapi path module, and api docs page - update EdgeAgentAdapter, agent-keys store, configuration, OpenAPI index, CHANGELOG [Unreleased], README, docs meta --- CHANGELOG.md | 2 +- README.md | 8 +- app/agent/EdgeAgentAdapter.test.ts | 4 +- app/agent/EdgeAgentAdapter.ts | 10 +- app/api/api.test.ts | 24 +- app/api/api.ts | 10 +- app/api/index.test.ts | 36 +-- app/api/index.ts | 10 +- app/api/openapi/index.ts | 4 +- app/api/openapi/paths/index.ts | 4 +- .../{lookout.test.ts => portwing.test.ts} | 80 +++---- .../openapi/paths/{lookout.ts => portwing.ts} | 20 +- ...lookout-ws.test.ts => portwing-ws.test.ts} | 218 ++++++++++-------- app/api/{lookout-ws.ts => portwing-ws.ts} | 45 ++-- app/api/{lookout.test.ts => portwing.test.ts} | 10 +- app/api/{lookout.ts => portwing.ts} | 14 +- app/configuration/index.test.ts | 38 +-- app/configuration/index.ts | 8 +- app/store/agent-keys.test.ts | 2 +- app/store/agent-keys.ts | 4 +- content/docs/current/api/agent.mdx | 4 +- content/docs/current/api/meta.json | 2 +- .../current/api/{lookout.mdx => portwing.mdx} | 52 ++--- 23 files changed, 322 insertions(+), 287 deletions(-) rename app/api/openapi/paths/{lookout.test.ts => portwing.test.ts} (74%) rename app/api/openapi/paths/{lookout.ts => portwing.ts} (92%) rename app/api/{lookout-ws.test.ts => portwing-ws.test.ts} (92%) rename app/api/{lookout-ws.ts => portwing-ws.ts} (93%) rename app/api/{lookout.test.ts => portwing.test.ts} (97%) rename app/api/{lookout.ts => portwing.ts} (90%) rename content/docs/current/api/{lookout.mdx => portwing.mdx} (78%) diff --git a/CHANGELOG.md b/CHANGELOG.md index e69c39f9..e7860959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ scheme restriction) live in `UPGRADE-NOTES.md` and are auto-appended to every ### Added -- **Experimental edge-agent mode — agents behind NAT or firewalls can now dial OUT to drydock over a persistent `wss://` WebSocket instead of waiting for an inbound controller connection ([PR #429](https://github.com/CodesWhat/drydock/pull/429), M5).** The feature is **experimental** and opt-in: set `DD_EXPERIMENTAL_LOOKOUT=true` to enable it; when the variable is unset or `false` the endpoint is not mounted and the feature has zero runtime footprint. Once enabled, agents connect to `WS /api/v1/lookout/ws` using the `lookout/1.0` subprotocol; authentication is Ed25519 public-key challenge-response with timestamp + nonce replay protection (±60 s clock-skew window, 16 MB maximum frame size). Operator key management is exposed through a REST registry at `/api/v1/lookout/keys` (list, register, revoke). Because the feature is experimental the protocol and API surface may change in a future release without a deprecation notice. +- **Experimental Portwing edge-agent mode — agents behind NAT or firewalls can now dial OUT to drydock over a persistent `wss://` WebSocket instead of waiting for an inbound controller connection ([PR #429](https://github.com/CodesWhat/drydock/pull/429), M5).** The feature is **experimental** and opt-in: set `DD_EXPERIMENTAL_PORTWING=true` to enable it. When disabled, the endpoint is not mounted and the feature has zero runtime footprint. Once enabled, agents connect to `WS /api/v1/portwing/ws` using the `portwing/1.0` subprotocol. Authentication is Ed25519 public-key challenge-response with timestamp + nonce replay protection (±60 s clock-skew window, 16 MB maximum frame size). Operator key management is exposed through a REST registry at `/api/v1/portwing/keys` (list, register, revoke). Because the feature is experimental the protocol and API surface may change in a future release without a deprecation notice. - **Remote-agent runtime info now carries `logLevel` and `pollInterval` in the acknowledgement payload ([PR #430](https://github.com/CodesWhat/drydock/pull/430), M4).** Drydock threads these fields through `buildRuntimeInfoFromAck` and surfaces them in the Agents view alongside the existing runtime metadata. diff --git a/README.md b/README.md index 227ecef9..278fc66a 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,7 @@ Auto-pull and recreate services via Docker Engine API with YAML-preserving servi

Distributed Agents

-Monitor remote Docker hosts with SSE-based agent architecture. Experimental: edge agents behind NAT/firewall dial out to drydock via WebSocket with Ed25519 public-key auth — no inbound port required (DD_EXPERIMENTAL_LOOKOUT=true) +Monitor remote Docker hosts with SSE-based agent architecture. Experimental: edge agents behind NAT/firewall dial out to drydock via WebSocket with Ed25519 public-key auth — no inbound port required (DD_EXPERIMENTAL_PORTWING=true)

Audit Log

@@ -414,7 +414,7 @@ High-level themes only — see [CHANGELOG.md](CHANGELOG.md) for per-release deta | --- | --- | --- | | **v1.3.x** ✅ | Security & Stability | Trivy scanning, Update Bouncer, SBOM, 7 new registries, 4 new triggers, re2js regex engine | | **v1.4.x** ✅ | UI Modernization & Hardening | Tailwind 4 + custom components, 6 themes, Cmd/K palette, OpenAPI 3.1, compose-native YAML updates, dual-slot scanning, OIDC hardening | -| **v1.5.0** | Observability & i18n | WebSocket log viewer, dashboard customization, resource monitoring, registry webhook receiver, security scan digest, 17 locales, SSE Last-Event-ID replay, edge agent dial-out with Ed25519 auth (experimental, `DD_EXPERIMENTAL_LOOKOUT=true`) | +| **v1.5.0** | Observability & i18n | WebSocket log viewer, dashboard customization, resource monitoring, registry webhook receiver, security scan digest, 17 locales, SSE Last-Event-ID replay, edge agent dial-out with Ed25519 auth (experimental, `DD_EXPERIMENTAL_PORTWING=true`) | | **v1.6.0** | Scanner Decoupling & Release Intel | Backend-based scanner + Grype, notification templates, declarative update policy, table-only UI, SBOM off-heap storage | | **v1.7.0** | Smart Updates & UX | Dependency-aware ordering, image prune, static image monitoring, keyboard shortcuts, PWA | | **v1.8.0** | Fleet Management & Live Config | YAML config, live UI config, volume browser, parallel updates, SQLite store migration | @@ -489,11 +489,11 @@ Thanks to the users who helped test v1.4.0 and v1.5.0 release candidates and rep - +
ToolRole
drydockContainer update monitoring — web UI and notification engine
lookoutRemote Docker agent — secure socket-level access from Drydock or standalone
portwingRemote Docker agent — secure socket-level access from Drydock or standalone
sockguardDocker socket proxy — default-deny allowlist filter protecting the socket
-These three tools are designed to layer: sockguard filters the socket, lookout exposes it remotely, and drydock monitors and acts on container state. +These three tools are designed to layer: sockguard filters the socket, portwing exposes it remotely, and drydock monitors and acts on container state. --- diff --git a/app/agent/EdgeAgentAdapter.test.ts b/app/agent/EdgeAgentAdapter.test.ts index 397af934..aaa988fc 100644 --- a/app/agent/EdgeAgentAdapter.test.ts +++ b/app/agent/EdgeAgentAdapter.test.ts @@ -99,7 +99,7 @@ function createMockWs(): WebSocketLike & { function createHello(overrides: Partial = {}): HelloMessage { return { version: '0.2.0', - protocol: 'lookout/1.0', + protocol: 'portwing/1.0', agentId: 'test-agent-id-1234', agentName: 'test-agent', dockerVersion: '27.0.0', @@ -117,7 +117,7 @@ function createHello(overrides: Partial = {}): HelloMessage { function createAdapter(hello?: Partial) { const ws = createMockWs(); const helloMsg = createHello(hello); - const client = new AgentClient(`lookout-edge-${helloMsg.agentId}`, { + const client = new AgentClient(`portwing-edge-${helloMsg.agentId}`, { host: 'http://edge-agent-placeholder', port: 0, secret: '', diff --git a/app/agent/EdgeAgentAdapter.ts b/app/agent/EdgeAgentAdapter.ts index 19d05224..a0f58143 100644 --- a/app/agent/EdgeAgentAdapter.ts +++ b/app/agent/EdgeAgentAdapter.ts @@ -1,10 +1,10 @@ /** * EdgeAgentAdapter — drives the existing AgentClient pipeline for edge agents - * that connect via the lookout/1.0 WebSocket protocol instead of the SSE path. + * that connect via the portwing/1.0 WebSocket protocol instead of the SSE path. * * After a successful hello/welcome handshake the gateway calls: * new EdgeAgentAdapter(client, ws, hello, config) - * The adapter then owns the WebSocket and translates incoming lookout frames + * The adapter then owns the WebSocket and translates incoming Portwing frames * into AgentClient pipeline calls. */ import { emitAgentConnected, emitAgentDisconnected } from '../event/index.js'; @@ -63,7 +63,7 @@ interface AgentComponentDescriptor { configuration: Record; } -interface LookoutFrame { +interface PortwingFrame { type: string; data: Record; } @@ -157,9 +157,9 @@ export class EdgeAgentAdapter { } private async onMessage(raw: unknown): Promise { - let frame: LookoutFrame; + let frame: PortwingFrame; try { - frame = JSON.parse(String(raw)) as LookoutFrame; + frame = JSON.parse(String(raw)) as PortwingFrame; } catch { log.warn(`${this.agentName}: non-JSON frame received`); return; diff --git a/app/api/api.test.ts b/app/api/api.test.ts index 7a5d85ae..2d4408ba 100644 --- a/app/api/api.test.ts +++ b/app/api/api.test.ts @@ -89,15 +89,15 @@ vi.mock('./rate-limit-key.js', () => ({ isIdentityAwareRateLimitKeyingEnabled: mockIsIdentityAwareRateLimitKeyingEnabled, })); -const mockGetExperimentalLookoutEnabled = vi.hoisted(() => vi.fn(() => false)); +const mockGetExperimentalPortwingEnabled = vi.hoisted(() => vi.fn(() => false)); vi.mock('../configuration/index.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - getExperimentalLookoutEnabled: mockGetExperimentalLookoutEnabled, + getExperimentalPortwingEnabled: mockGetExperimentalPortwingEnabled, }; }); -vi.mock('./lookout', mockInit); +vi.mock('./portwing', mockInit); describe('API Router', () => { let api; @@ -108,7 +108,7 @@ describe('API Router', () => { resetMockRouterCallLog(); mockIsIdentityAwareRateLimitKeyingEnabled.mockReturnValue(false); mockCreateAuthenticatedRouteRateLimitKeyGenerator.mockReturnValue(undefined); - mockGetExperimentalLookoutEnabled.mockReturnValue(false); + mockGetExperimentalPortwingEnabled.mockReturnValue(false); vi.resetModules(); api = await import('./api.js'); router = api.init(); @@ -453,27 +453,27 @@ describe('API Router', () => { ); }); - test('should mount /lookout router when DD_EXPERIMENTAL_LOOKOUT is enabled', async () => { - mockGetExperimentalLookoutEnabled.mockReturnValue(true); + test('should mount only /portwing router when DD_EXPERIMENTAL_PORTWING is enabled', async () => { + mockGetExperimentalPortwingEnabled.mockReturnValue(true); vi.resetModules(); const isolatedApi = await import('./api.js'); const isolatedRouter = isolatedApi.init(); const useCalls = isolatedRouter.use.mock.calls; - const lookoutMount = useCalls.find((c) => c[0] === '/lookout'); - expect(lookoutMount).toBeDefined(); + const portwingMount = useCalls.find((c) => c[0] === '/portwing'); + expect(portwingMount).toBeDefined(); }); - test('should NOT mount /lookout router when DD_EXPERIMENTAL_LOOKOUT is disabled', async () => { - mockGetExperimentalLookoutEnabled.mockReturnValue(false); + test('should not mount /portwing router when DD_EXPERIMENTAL_PORTWING is disabled', async () => { + mockGetExperimentalPortwingEnabled.mockReturnValue(false); vi.resetModules(); const isolatedApi = await import('./api.js'); const isolatedRouter = isolatedApi.init(); const useCalls = isolatedRouter.use.mock.calls; - const lookoutMount = useCalls.find((c) => c[0] === '/lookout'); - expect(lookoutMount).toBeUndefined(); + const portwingMount = useCalls.find((c) => c[0] === '/portwing'); + expect(portwingMount).toBeUndefined(); }); }); diff --git a/app/api/api.ts b/app/api/api.ts index e4586975..1ce43a2e 100644 --- a/app/api/api.ts +++ b/app/api/api.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express'; import express from 'express'; import rateLimit from 'express-rate-limit'; -import { getExperimentalLookoutEnabled, getServerConfiguration } from '../configuration/index.js'; +import { getExperimentalPortwingEnabled, getServerConfiguration } from '../configuration/index.js'; import * as agentRouter from './agent.js'; import * as appRouter from './app.js'; import * as auditRouter from './audit.js'; @@ -18,10 +18,10 @@ import * as iconsRouter from './icons.js'; import * as internalSelfUpdateRouter from './internal-self-update.js'; import { requireJsonContentTypeForMutations, shouldParseJsonBody } from './json-content-type.js'; import * as logRouter from './log.js'; -import * as lookoutRouter from './lookout.js'; import * as notificationRouter from './notification.js'; import * as notificationOutboxRouter from './notification-outbox.js'; import * as operationRouter from './operation.js'; +import * as portwingRouter from './portwing.js'; import * as previewRouter from './preview.js'; import { createAuthenticatedRouteRateLimitKeyGenerator, @@ -173,9 +173,9 @@ export function init(): express.Router { // Mount agents router.use('/agents', agentRouter.init()); - // Mount lookout key management (edge agent auth registry) — experimental - if (getExperimentalLookoutEnabled()) { - router.use('/lookout', lookoutRouter.init()); + // Mount Portwing key management (edge agent auth registry) — experimental. + if (getExperimentalPortwingEnabled()) { + router.use('/portwing', portwingRouter.init()); } // Mount audit log diff --git a/app/api/index.test.ts b/app/api/index.test.ts index 975cdb04..e4d4c73d 100644 --- a/app/api/index.test.ts +++ b/app/api/index.test.ts @@ -57,8 +57,8 @@ const mockIsInternetlessModeEnabled = vi.hoisted(() => vi.fn(() => false)); const mockGetSessionMiddleware = vi.hoisted(() => vi.fn(() => vi.fn())); const mockAttachContainerLogStreamWebSocketServer = vi.hoisted(() => vi.fn()); const mockAttachSystemLogStreamWebSocketServer = vi.hoisted(() => vi.fn()); -const mockAttachLookoutWsServer = vi.hoisted(() => vi.fn()); -const mockGetExperimentalLookoutEnabled = vi.hoisted(() => vi.fn(() => false)); +const mockAttachPortwingWsServer = vi.hoisted(() => vi.fn()); +const mockGetExperimentalPortwingEnabled = vi.hoisted(() => vi.fn(() => false)); vi.mock('node:fs', () => ({ default: mockFs, @@ -135,14 +135,14 @@ vi.mock('./log-stream', () => ({ attachSystemLogStreamWebSocketServer: mockAttachSystemLogStreamWebSocketServer, })); -vi.mock('./lookout-ws', () => ({ - attachLookoutWsServer: mockAttachLookoutWsServer, +vi.mock('./portwing-ws', () => ({ + attachPortwingWsServer: mockAttachPortwingWsServer, })); vi.mock('../configuration', () => ({ getServerConfiguration: mockGetServerConfiguration, ddEnvVars: mockDdEnvVars, - getExperimentalLookoutEnabled: mockGetExperimentalLookoutEnabled, + getExperimentalPortwingEnabled: mockGetExperimentalPortwingEnabled, })); vi.mock('../store/settings', () => ({ @@ -167,7 +167,7 @@ function mockActualApiRouterStatsLifecycle() { vi.doMock('../configuration/index.js', () => ({ getServerConfiguration: mockGetServerConfiguration, ddEnvVars: mockDdEnvVars, - getExperimentalLookoutEnabled: mockGetExperimentalLookoutEnabled, + getExperimentalPortwingEnabled: mockGetExperimentalPortwingEnabled, })); vi.doMock('../stats/aggregator.js', () => ({ createContainerStatsAggregator: mockCreateContainerStatsAggregator, @@ -217,7 +217,7 @@ function mockActualApiRouterStatsLifecycle() { './icons.js', './internal-self-update.js', './log.js', - './lookout.js', + './portwing.js', './notification.js', './notification-outbox.js', './operation.js', @@ -263,8 +263,8 @@ describe('API Index', () => { mockGetSessionMiddleware.mockReturnValue(vi.fn()); mockAttachContainerLogStreamWebSocketServer.mockClear(); mockAttachSystemLogStreamWebSocketServer.mockClear(); - mockAttachLookoutWsServer.mockClear(); - mockGetExperimentalLookoutEnabled.mockReturnValue(false); + mockAttachPortwingWsServer.mockClear(); + mockGetExperimentalPortwingEnabled.mockReturnValue(false); Object.keys(mockDdEnvVars).forEach((key) => delete mockDdEnvVars[key]); }); @@ -294,7 +294,7 @@ describe('API Index', () => { cors: {}, tls: {}, }); - mockGetExperimentalLookoutEnabled.mockReturnValue(true); + mockGetExperimentalPortwingEnabled.mockReturnValue(true); vi.resetModules(); const indexRouter = await import('./index.js'); @@ -313,7 +313,7 @@ describe('API Index', () => { serverConfiguration: expect.objectContaining({ enabled: true }), isRateLimited: expect.any(Function), }); - expect(mockAttachLookoutWsServer).toHaveBeenCalledWith({ + expect(mockAttachPortwingWsServer).toHaveBeenCalledWith({ server: mockHttpServer, serverConfiguration: expect.objectContaining({ enabled: true }), isRateLimited: expect.any(Function), @@ -1291,42 +1291,42 @@ describe('API Index', () => { expect(mockStatsAggregator.start).not.toHaveBeenCalled(); }); - test('should attach lookout WS server when DD_EXPERIMENTAL_LOOKOUT is enabled', async () => { + test('should attach Portwing WS server when DD_EXPERIMENTAL_PORTWING is enabled', async () => { mockGetServerConfiguration.mockReturnValue({ enabled: true, port: 3000, cors: {}, tls: {}, }); - mockGetExperimentalLookoutEnabled.mockReturnValue(true); + mockGetExperimentalPortwingEnabled.mockReturnValue(true); vi.resetModules(); const indexRouter = await import('./index.js'); await indexRouter.init(); - expect(mockAttachLookoutWsServer).toHaveBeenCalledWith({ + expect(mockAttachPortwingWsServer).toHaveBeenCalledWith({ server: mockHttpServer, serverConfiguration: expect.objectContaining({ enabled: true }), isRateLimited: expect.any(Function), }); expect(mockLog.info).toHaveBeenCalledWith( - 'lookout/1.0 edge endpoint enabled (experimental, DD_EXPERIMENTAL_LOOKOUT=true)', + 'portwing/1.0 edge endpoint enabled (experimental, DD_EXPERIMENTAL_PORTWING=true)', ); }); - test('should NOT attach lookout WS server when DD_EXPERIMENTAL_LOOKOUT is disabled', async () => { + test('should NOT attach Portwing WS server when DD_EXPERIMENTAL_PORTWING is disabled', async () => { mockGetServerConfiguration.mockReturnValue({ enabled: true, port: 3000, cors: {}, tls: {}, }); - mockGetExperimentalLookoutEnabled.mockReturnValue(false); + mockGetExperimentalPortwingEnabled.mockReturnValue(false); vi.resetModules(); const indexRouter = await import('./index.js'); await indexRouter.init(); - expect(mockAttachLookoutWsServer).not.toHaveBeenCalled(); + expect(mockAttachPortwingWsServer).not.toHaveBeenCalled(); }); }); diff --git a/app/api/index.ts b/app/api/index.ts index 339d025c..37ee383e 100644 --- a/app/api/index.ts +++ b/app/api/index.ts @@ -12,7 +12,7 @@ const log = logger.child({ component: 'api' }); import { ddEnvVars, - getExperimentalLookoutEnabled, + getExperimentalPortwingEnabled, getServerConfiguration, } from '../configuration/index.js'; import * as settingsStore from '../store/settings.js'; @@ -23,7 +23,7 @@ import { attachContainerLogStreamWebSocketServer } from './container/log-stream. import { sendErrorResponse } from './error-response.js'; import * as healthRouter from './health.js'; import { attachSystemLogStreamWebSocketServer } from './log-stream.js'; -import { attachLookoutWsServer } from './lookout-ws.js'; +import { attachPortwingWsServer } from './portwing-ws.js'; import * as prometheusRouter from './prometheus.js'; import * as uiRouter from './ui.js'; import { createFixedWindowRateLimiter } from './ws-upgrade-utils.js'; @@ -304,9 +304,9 @@ export async function init() { serverConfiguration: configuration as Record, isRateLimited, }); - if (getExperimentalLookoutEnabled()) { - log.info('lookout/1.0 edge endpoint enabled (experimental, DD_EXPERIMENTAL_LOOKOUT=true)'); - attachLookoutWsServer({ + if (getExperimentalPortwingEnabled()) { + log.info('portwing/1.0 edge endpoint enabled (experimental, DD_EXPERIMENTAL_PORTWING=true)'); + attachPortwingWsServer({ server, serverConfiguration: configuration as Record, isRateLimited, diff --git a/app/api/openapi/index.ts b/app/api/openapi/index.ts index 901e79f5..2c59672d 100644 --- a/app/api/openapi/index.ts +++ b/app/api/openapi/index.ts @@ -43,8 +43,8 @@ export const openApiDocument = { { name: 'Metrics', description: 'Prometheus metrics endpoint' }, { name: 'Docs', description: 'API documentation endpoints' }, { - name: 'Lookout', - description: 'Edge agent key registry — experimental, requires DD_EXPERIMENTAL_LOOKOUT=true', + name: 'Portwing', + description: 'Edge agent key registry — experimental, requires DD_EXPERIMENTAL_PORTWING=true', }, ], security: [{ sessionAuth: [] }], diff --git a/app/api/openapi/paths/index.ts b/app/api/openapi/paths/index.ts index 104df0e9..f30f1864 100644 --- a/app/api/openapi/paths/index.ts +++ b/app/api/openapi/paths/index.ts @@ -12,8 +12,8 @@ import { import { authPaths } from './auth.js'; import { componentReadPaths } from './component-read.js'; import { containerPaths } from './containers.js'; -import { lookoutPaths } from './lookout.js'; import { notificationOutboxPaths } from './notification-outbox.js'; +import { portwingPaths } from './portwing.js'; import { statsPaths } from './stats.js'; import { triggerPaths } from './triggers.js'; @@ -417,7 +417,7 @@ export const openApiPaths = { }, }, ...triggerPaths, - ...lookoutPaths, + ...portwingPaths, ...componentReadPaths, '/api/agents': { get: { diff --git a/app/api/openapi/paths/lookout.test.ts b/app/api/openapi/paths/portwing.test.ts similarity index 74% rename from app/api/openapi/paths/lookout.test.ts rename to app/api/openapi/paths/portwing.test.ts index ff6408a1..78244988 100644 --- a/app/api/openapi/paths/lookout.test.ts +++ b/app/api/openapi/paths/portwing.test.ts @@ -1,26 +1,26 @@ import { describe, expect, test } from 'vitest'; import { errorResponse, noContentResponse } from '../common.js'; -import { lookoutPaths } from './lookout.js'; +import { portwingPaths } from './portwing.js'; -describe('lookoutPaths', () => { - describe('/api/v1/lookout/keys GET', () => { - const getPath = lookoutPaths['/api/v1/lookout/keys'].get; +describe('portwingPaths', () => { + describe('/api/v1/portwing/keys GET', () => { + const getPath = portwingPaths['/api/v1/portwing/keys'].get; - test('has tag Lookout', () => { - expect(getPath.tags).toStrictEqual(['Lookout']); + test('has tag Portwing', () => { + expect(getPath.tags).toStrictEqual(['Portwing']); }); - test('operationId is listLookoutKeys', () => { - expect(getPath.operationId).toBe('listLookoutKeys'); + test('operationId is listPortwingKeys', () => { + expect(getPath.operationId).toBe('listPortwingKeys'); }); test('summary is correct', () => { expect(getPath.summary).toBe('List all registered edge-agent keys'); }); - test('description mentions EXPERIMENTAL and DD_EXPERIMENTAL_LOOKOUT=true', () => { + test('description mentions EXPERIMENTAL and DD_EXPERIMENTAL_PORTWING=true', () => { expect(getPath.description).toContain('EXPERIMENTAL'); - expect(getPath.description).toContain('DD_EXPERIMENTAL_LOOKOUT=true'); + expect(getPath.description).toContain('DD_EXPERIMENTAL_PORTWING=true'); }); test('200 response is a json array', () => { @@ -63,24 +63,24 @@ describe('lookoutPaths', () => { }); }); - describe('/api/v1/lookout/keys POST', () => { - const postPath = lookoutPaths['/api/v1/lookout/keys'].post; + describe('/api/v1/portwing/keys POST', () => { + const postPath = portwingPaths['/api/v1/portwing/keys'].post; - test('has tag Lookout', () => { - expect(postPath.tags).toStrictEqual(['Lookout']); + test('has tag Portwing', () => { + expect(postPath.tags).toStrictEqual(['Portwing']); }); - test('operationId is createLookoutKey', () => { - expect(postPath.operationId).toBe('createLookoutKey'); + test('operationId is createPortwingKey', () => { + expect(postPath.operationId).toBe('createPortwingKey'); }); test('summary is correct', () => { expect(postPath.summary).toBe('Register a new authorized edge-agent key'); }); - test('description mentions EXPERIMENTAL and DD_EXPERIMENTAL_LOOKOUT=true', () => { + test('description mentions EXPERIMENTAL and DD_EXPERIMENTAL_PORTWING=true', () => { expect(postPath.description).toContain('EXPERIMENTAL'); - expect(postPath.description).toContain('DD_EXPERIMENTAL_LOOKOUT=true'); + expect(postPath.description).toContain('DD_EXPERIMENTAL_PORTWING=true'); }); test('request body requires pubkeyBase64 and label', () => { @@ -132,24 +132,24 @@ describe('lookoutPaths', () => { }); }); - describe('/api/v1/lookout/keys/{keyId} DELETE', () => { - const deletePath = lookoutPaths['/api/v1/lookout/keys/{keyId}'].delete; + describe('/api/v1/portwing/keys/{keyId} DELETE', () => { + const deletePath = portwingPaths['/api/v1/portwing/keys/{keyId}'].delete; - test('has tag Lookout', () => { - expect(deletePath.tags).toStrictEqual(['Lookout']); + test('has tag Portwing', () => { + expect(deletePath.tags).toStrictEqual(['Portwing']); }); - test('operationId is revokeLookoutKey', () => { - expect(deletePath.operationId).toBe('revokeLookoutKey'); + test('operationId is revokePortwingKey', () => { + expect(deletePath.operationId).toBe('revokePortwingKey'); }); test('summary is correct', () => { expect(deletePath.summary).toBe('Revoke a registered edge-agent key'); }); - test('description mentions EXPERIMENTAL and DD_EXPERIMENTAL_LOOKOUT=true', () => { + test('description mentions EXPERIMENTAL and DD_EXPERIMENTAL_PORTWING=true', () => { expect(deletePath.description).toContain('EXPERIMENTAL'); - expect(deletePath.description).toContain('DD_EXPERIMENTAL_LOOKOUT=true'); + expect(deletePath.description).toContain('DD_EXPERIMENTAL_PORTWING=true'); }); test('keyId path param has correct name and pattern', () => { @@ -181,32 +181,32 @@ describe('lookoutPaths', () => { }); }); - test('lookoutPaths exports exactly the two expected path keys', () => { - expect(Object.keys(lookoutPaths)).toStrictEqual([ - '/api/v1/lookout/keys', - '/api/v1/lookout/keys/{keyId}', + test('portwingPaths exports exactly the two expected path keys', () => { + expect(Object.keys(portwingPaths)).toStrictEqual([ + '/api/v1/portwing/keys', + '/api/v1/portwing/keys/{keyId}', ]); }); - test('GET /api/v1/lookout/keys 200 response uses jsonResponse helper shape', () => { - const response = lookoutPaths['/api/v1/lookout/keys'].get.responses[200]; + test('GET /api/v1/portwing/keys 200 response uses jsonResponse helper shape', () => { + const response = portwingPaths['/api/v1/portwing/keys'].get.responses[200]; expect(response).toHaveProperty('description'); expect(response).toHaveProperty('content'); expect(response.content).toHaveProperty('application/json'); }); - test('POST /api/v1/lookout/keys 201 response uses jsonResponse helper shape', () => { - const response = lookoutPaths['/api/v1/lookout/keys'].post.responses[201]; + test('POST /api/v1/portwing/keys 201 response uses jsonResponse helper shape', () => { + const response = portwingPaths['/api/v1/portwing/keys'].post.responses[201]; expect(response).toHaveProperty('description'); expect(response).toHaveProperty('content'); expect(response.content).toHaveProperty('application/json'); }); test('full GET path matches expected shape', () => { - const getPath = lookoutPaths['/api/v1/lookout/keys'].get; + const getPath = portwingPaths['/api/v1/portwing/keys'].get; expect(getPath).toMatchObject({ - tags: ['Lookout'], - operationId: 'listLookoutKeys', + tags: ['Portwing'], + operationId: 'listPortwingKeys', responses: { 401: errorResponse('Authentication required'), }, @@ -214,10 +214,10 @@ describe('lookoutPaths', () => { }); test('full DELETE path matches expected shape', () => { - const deletePath = lookoutPaths['/api/v1/lookout/keys/{keyId}'].delete; + const deletePath = portwingPaths['/api/v1/portwing/keys/{keyId}'].delete; expect(deletePath).toMatchObject({ - tags: ['Lookout'], - operationId: 'revokeLookoutKey', + tags: ['Portwing'], + operationId: 'revokePortwingKey', responses: { 204: noContentResponse, 401: errorResponse('Authentication required'), diff --git a/app/api/openapi/paths/lookout.ts b/app/api/openapi/paths/portwing.ts similarity index 92% rename from app/api/openapi/paths/lookout.ts rename to app/api/openapi/paths/portwing.ts index 97fb39f0..d26795f0 100644 --- a/app/api/openapi/paths/lookout.ts +++ b/app/api/openapi/paths/portwing.ts @@ -34,7 +34,7 @@ const agentKeyRecord = { } as const; const experimentalNote = - 'EXPERIMENTAL: only available when the server is started with DD_EXPERIMENTAL_LOOKOUT=true.'; + 'EXPERIMENTAL: only available when the server is started with DD_EXPERIMENTAL_PORTWING=true.'; const keyIdPathParam = { name: 'keyId', @@ -44,12 +44,12 @@ const keyIdPathParam = { schema: { type: 'string', pattern: '^[0-9a-f]{16}$' }, } as const; -export const lookoutPaths = { - '/api/v1/lookout/keys': { +export const portwingPaths = { + '/api/v1/portwing/keys': { get: { - tags: ['Lookout'], + tags: ['Portwing'], summary: 'List all registered edge-agent keys', - operationId: 'listLookoutKeys', + operationId: 'listPortwingKeys', description: `Returns all keys — active and revoked. ${experimentalNote}`, responses: { 200: jsonResponse('Array of agent key records', { @@ -60,9 +60,9 @@ export const lookoutPaths = { }, }, post: { - tags: ['Lookout'], + tags: ['Portwing'], summary: 'Register a new authorized edge-agent key', - operationId: 'createLookoutKey', + operationId: 'createPortwingKey', description: `Registers a new Ed25519 public key for edge agent authentication. ${experimentalNote}`, requestBody: { required: true, @@ -108,11 +108,11 @@ export const lookoutPaths = { }, }, }, - '/api/v1/lookout/keys/{keyId}': { + '/api/v1/portwing/keys/{keyId}': { delete: { - tags: ['Lookout'], + tags: ['Portwing'], summary: 'Revoke a registered edge-agent key', - operationId: 'revokeLookoutKey', + operationId: 'revokePortwingKey', description: `Revokes the key and disconnects any live WebSocket session authenticated with it. ${experimentalNote}`, parameters: [keyIdPathParam], responses: { diff --git a/app/api/lookout-ws.test.ts b/app/api/portwing-ws.test.ts similarity index 92% rename from app/api/lookout-ws.test.ts rename to app/api/portwing-ws.test.ts index 2ab5cabd..985bfe97 100644 --- a/app/api/lookout-ws.test.ts +++ b/app/api/portwing-ws.test.ts @@ -1,5 +1,5 @@ /** - * Tests for the lookout/1.0 WebSocket gateway (lookout-ws.ts). + * Tests for the portwing/1.0 WebSocket gateway (portwing-ws.ts). * Real Ed25519 keypairs generated in-test via Node crypto. */ import { createHash, sign as cryptoSign, generateKeyPairSync } from 'node:crypto'; @@ -7,16 +7,16 @@ import type { IncomingMessage } from 'node:http'; import type { Socket } from 'node:net'; import type { AgentKeyRecord } from '../store/agent-keys.js'; import { - attachLookoutWsServer, + attachPortwingWsServer, clearLiveSessionsForTesting, clearNonceCacheForTesting, - createLookoutWsGateway, + createPortwingWsGateway, disconnectByKeyId, fillNonceCacheForTesting, fillNoncesPerKeyForTesting, injectDrydockVersionForTesting, - LOOKOUT_WS_ROUTE_PATTERN, -} from './lookout-ws.js'; + PORTWING_WS_ROUTE_PATTERN, +} from './portwing-ws.js'; vi.mock('../configuration/index.js', () => ({ getServerConfiguration: vi.fn(() => ({})), @@ -34,7 +34,7 @@ vi.mock('../log/index.js', () => ({ })); // Mock AgentClient as a proper constructor class so that `new AgentClient(...)` -// works in lookout-ws.ts. Arrow-factory implementations are not usable as +// works in portwing-ws.ts. Arrow-factory implementations are not usable as // constructors in newer Vitest versions — use vi.hoisted() exactly like // EdgeAgentAdapter.test.ts does. const { MockAgentClient, MockEdgeAgentAdapter } = vi.hoisted(() => { @@ -111,11 +111,12 @@ function signHello( privateKey: import('node:crypto').KeyObject, timestamp: number, nonce: string, + canonicalPath = '/api/portwing/ws', ): string { const canonical = Buffer.from( [ 'GET', - '/api/lookout/ws', + canonicalPath, 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', String(timestamp), nonce, @@ -209,9 +210,9 @@ function createGateway( ), }; - const gateway = createLookoutWsGateway({ + const gateway = createPortwingWsGateway({ webSocketServer: mockWsServer as unknown as Parameters< - typeof createLookoutWsGateway + typeof createPortwingWsGateway >[0]['webSocketServer'], isRateLimited: options.isRateLimited ?? (() => false), serverConfiguration: {}, @@ -236,7 +237,7 @@ function buildHello( type: 'hello', data: { version: '0.2.0', - protocol: 'lookout/1.0', + protocol: 'portwing/1.0', agentId: 'test-agent-123', agentName: 'test-agent', dockerVersion: '27.0.0', @@ -254,21 +255,21 @@ function buildHello( // ---- Tests ---- -describe('LOOKOUT_WS_ROUTE_PATTERN', () => { - test('matches /api/lookout/ws', () => { - expect(LOOKOUT_WS_ROUTE_PATTERN.test('/api/lookout/ws')).toBe(true); +describe('PORTWING_WS_ROUTE_PATTERN', () => { + test('matches canonical /api/portwing/ws', () => { + expect(PORTWING_WS_ROUTE_PATTERN.test('/api/portwing/ws')).toBe(true); }); - test('matches /api/v1/lookout/ws', () => { - expect(LOOKOUT_WS_ROUTE_PATTERN.test('/api/v1/lookout/ws')).toBe(true); + test('matches canonical /api/v1/portwing/ws', () => { + expect(PORTWING_WS_ROUTE_PATTERN.test('/api/v1/portwing/ws')).toBe(true); }); - test('does not match /api/lookout/ws/extra', () => { - expect(LOOKOUT_WS_ROUTE_PATTERN.test('/api/lookout/ws/extra')).toBe(false); + test('does not match /api/portwing/ws/extra', () => { + expect(PORTWING_WS_ROUTE_PATTERN.test('/api/portwing/ws/extra')).toBe(false); }); test('does not match /api/containers', () => { - expect(LOOKOUT_WS_ROUTE_PATTERN.test('/api/containers')).toBe(false); + expect(PORTWING_WS_ROUTE_PATTERN.test('/api/containers')).toBe(false); }); }); @@ -293,7 +294,7 @@ describe('handleUpgrade — pre-upgrade checks', () => { test('returns 403 when Origin header is present and mismatched', () => { const { gateway } = createGateway(null, {}); const socket = createMockSocket(); - const request = createRequest('/api/lookout/ws', { + const request = createRequest('/api/portwing/ws', { origin: 'http://evil.example.com', host: 'mydrydock.example.com', }); @@ -307,7 +308,7 @@ describe('handleUpgrade — pre-upgrade checks', () => { test('returns 429 when rate limited', () => { const { gateway } = createGateway(null, { isRateLimited: () => true }); const socket = createMockSocket(); - const request = createRequest('/api/lookout/ws'); + const request = createRequest('/api/portwing/ws'); gateway.handleUpgrade(request, socket as unknown as Socket, Buffer.alloc(0)); @@ -329,7 +330,7 @@ describe('handleUpgrade — hello timeout', () => { test('closes with 1008 hello timeout when no message arrives within 30s', async () => { const { gateway, getUpgradedWs } = createGateway(); const socket = createMockSocket(); - const request = createRequest('/api/lookout/ws'); + const request = createRequest('/api/portwing/ws'); gateway.handleUpgrade(request, socket as unknown as Socket, Buffer.alloc(0)); const ws = getUpgradedWs()!; @@ -353,7 +354,7 @@ describe('hello verification — rejection paths', () => { ) { const { gateway, getUpgradedWs } = createGateway(keyRecord, opts); const socket = createMockSocket(); - const request = createRequest('/api/lookout/ws'); + const request = createRequest('/api/portwing/ws'); gateway.handleUpgrade(request, socket as unknown as Socket, Buffer.alloc(0)); const ws = getUpgradedWs()!; @@ -366,7 +367,7 @@ describe('hello verification — rejection paths', () => { const { gateway, getUpgradedWs } = createGateway(); const socket = createMockSocket(); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), socket as unknown as Socket, Buffer.alloc(0), ); @@ -390,7 +391,7 @@ describe('hello verification — rejection paths', () => { expect(ws.close).toHaveBeenCalledWith(1008, 'expected-hello'); }); - test('protocol-mismatch when protocol is lookout/1', async () => { + test('protocol-mismatch when protocol is portwing/1', async () => { const { privateKey, pubkeyBase64, keyId } = generateKeyPair(); const ts = Math.floor(Date.now() / 1000); const nonce = makeNonce(); @@ -406,7 +407,7 @@ describe('hello verification — rejection paths', () => { const { ws } = doHandshake( record, - buildHello(keyId, ts, nonce, sig, { protocol: 'lookout/1' }), + buildHello(keyId, ts, nonce, sig, { protocol: 'portwing/1' }), ); await new Promise((r) => setTimeout(r, 0)); @@ -415,7 +416,7 @@ describe('hello verification — rejection paths', () => { expect(ws.close).toHaveBeenCalledWith(1008, 'protocol-mismatch'); }); - test('protocol-mismatch when protocol is lookout/2.0', async () => { + test('protocol-mismatch when protocol is portwing/2.0', async () => { const { privateKey, pubkeyBase64, keyId } = generateKeyPair(); const ts = Math.floor(Date.now() / 1000); const nonce = makeNonce(); @@ -431,7 +432,7 @@ describe('hello verification — rejection paths', () => { const { ws } = doHandshake( record, - buildHello(keyId, ts, nonce, sig, { protocol: 'lookout/2.0' }), + buildHello(keyId, ts, nonce, sig, { protocol: 'portwing/2.0' }), ); await new Promise((r) => setTimeout(r, 0)); @@ -444,7 +445,7 @@ describe('hello verification — rejection paths', () => { type: 'hello', data: { version: '0.2.0', - protocol: 'lookout/1.0', + protocol: 'portwing/1.0', agentId: 'test', agentName: 'test', dockerVersion: 'unknown', @@ -464,7 +465,7 @@ describe('hello verification — rejection paths', () => { type: 'hello', data: { version: '0.2.0', - protocol: 'lookout/1.0', + protocol: 'portwing/1.0', agentId: 'test', agentName: 'test', dockerVersion: 'unknown', @@ -584,7 +585,7 @@ describe('hello verification — rejection paths', () => { // First connection: valid hello — should succeed (welcome sent) and seed the nonce. const { gateway: gw1, getUpgradedWs: getWs1 } = createGateway(record); gw1.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -600,7 +601,7 @@ describe('hello verification — rejection paths', () => { // Second connection: same nonce — must be rejected with exactly 'replay'. const { gateway: gw2, getUpgradedWs: getWs2 } = createGateway(record); gw2.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -795,7 +796,7 @@ describe('hello verification — rejection paths', () => { // return 'replay'; with the fix it must proceed to the welcome. const { gateway: gw2, getUpgradedWs: getWs2 } = createGateway(record); gw2.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -831,7 +832,7 @@ describe('hello verification — happy path', () => { const { gateway, getUpgradedWs } = createGateway(record); const socket = createMockSocket(); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), socket as unknown as Socket, Buffer.alloc(0), ); @@ -855,10 +856,10 @@ describe('hello verification — happy path', () => { expect(welcome.type).toBe('welcome'); expect(welcome.data.pollInterval).toBeGreaterThan(0); expect(welcome.data.config.serverCompatLevel).toBe('1.4'); - expect(welcome.data.config.supportedProtocols).toBe('lookout/1.0'); + expect(welcome.data.config.supportedProtocols).toBe('portwing/1.0'); }); - test('protocol lookout/1.0 is accepted', async () => { + test('protocol portwing/1.0 is accepted', async () => { const { privateKey, pubkeyBase64, keyId } = generateKeyPair(); const ts = Math.floor(Date.now() / 1000); const nonce = 'e1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6'; @@ -875,7 +876,7 @@ describe('hello verification — happy path', () => { const { gateway, getUpgradedWs } = createGateway(record); const socket = createMockSocket(); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), socket as unknown as Socket, Buffer.alloc(0), ); @@ -887,6 +888,39 @@ describe('hello verification — happy path', () => { const welcome = JSON.parse(ws.sentMessages[0]) as { type: string }; expect(welcome.type).toBe('welcome'); }); + + test('protocol portwing/1.0 is accepted with Portwing canonical signature path', async () => { + const { privateKey, pubkeyBase64, keyId } = generateKeyPair(); + const ts = Math.floor(Date.now() / 1000); + const nonce = 'e2b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6'; + const sig = signHello(privateKey, ts, nonce, '/api/portwing/ws'); + + const record: AgentKeyRecord = { + keyId, + pubkey: pubkeyBase64, + label: 'test', + createdAt: new Date().toISOString(), + revokedAt: null, + }; + + const { gateway, getUpgradedWs } = createGateway(record); + const socket = createMockSocket(); + gateway.handleUpgrade( + createRequest('/api/v1/portwing/ws'), + socket as unknown as Socket, + Buffer.alloc(0), + ); + const ws = getUpgradedWs()!; + sendMessageToGateway(ws, buildHello(keyId, ts, nonce, sig, { protocol: 'portwing/1.0' })); + await new Promise((r) => setTimeout(r, 10)); + + const welcome = JSON.parse(ws.sentMessages[0]) as { + type: string; + data: { config: { supportedProtocols: string } }; + }; + expect(welcome.type).toBe('welcome'); + expect(welcome.data.config.supportedProtocols).toBe('portwing/1.0'); + }); }); describe('version handshake', () => { @@ -895,7 +929,7 @@ describe('version handshake', () => { clearNonceCacheForTesting(); }); - test('welcome config includes serverCompatLevel=1.4 and supportedProtocols=lookout/1.0', async () => { + test('welcome config includes serverCompatLevel=1.4 and supportedProtocols=portwing/1.0', async () => { const { privateKey, pubkeyBase64, keyId } = generateKeyPair(); const ts = Math.floor(Date.now() / 1000); const nonce = 'f1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6'; @@ -911,7 +945,7 @@ describe('version handshake', () => { const { gateway, getUpgradedWs } = createGateway(record); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -923,7 +957,7 @@ describe('version handshake', () => { data: { config: { serverCompatLevel: string; supportedProtocols: string } }; }; expect(welcome.data.config.serverCompatLevel).toBe('1.4'); - expect(welcome.data.config.supportedProtocols).toBe('lookout/1.0'); + expect(welcome.data.config.supportedProtocols).toBe('portwing/1.0'); }); }); @@ -949,7 +983,7 @@ describe('duplicate-agent guard', () => { const sig = signHello(privateKey, ts, nonce); const socket = createMockSocket(); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), socket as unknown as Socket, Buffer.alloc(0), ); @@ -973,7 +1007,7 @@ describe('duplicate-agent guard', () => { // Make getAgent return a truthy value for any agent name (simulate existing connection) const { getAgent } = await import('../agent/manager.js'); vi.mocked(getAgent).mockReturnValue({ - name: 'lookout-edge-concurrent-agent', + name: 'portwing-edge-concurrent-agent', } as unknown as Parameters[0] extends string ? ReturnType : never); @@ -983,7 +1017,7 @@ describe('duplicate-agent guard', () => { const sig = signHello(privateKey, ts, nonce); const socket = createMockSocket(); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), socket as unknown as Socket, Buffer.alloc(0), ); @@ -1022,7 +1056,7 @@ describe('injectDrydockVersionForTesting', () => { const { gateway, getUpgradedWs } = createGateway(record); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -1053,7 +1087,7 @@ describe('startNoncePruning — setInterval callback', () => { test('prunes expired nonces and resets per-key counters after 60s', () => { vi.useFakeTimers(); - // createGateway calls startNoncePruning() inside createLookoutWsGateway() + // createGateway calls startNoncePruning() inside createPortwingWsGateway() createGateway(); // Seed an old nonce (timestamp 0 = epoch; will be expired after 60s+) @@ -1071,7 +1105,7 @@ describe('startNoncePruning — setInterval callback', () => { }); }); -describe('createLookoutWsGateway — default serverConfiguration branch', () => { +describe('createPortwingWsGateway — default serverConfiguration branch', () => { beforeEach(() => { vi.resetAllMocks(); clearNonceCacheForTesting(); @@ -1083,9 +1117,9 @@ describe('createLookoutWsGateway — default serverConfiguration branch', () => const mockWsServer = { handleUpgrade: vi.fn(), }; - const gateway = createLookoutWsGateway({ + const gateway = createPortwingWsGateway({ webSocketServer: mockWsServer as unknown as Parameters< - typeof createLookoutWsGateway + typeof createPortwingWsGateway >[0]['webSocketServer'], }); // Gateway object is returned; the default branch was exercised. @@ -1111,16 +1145,16 @@ describe('createLookoutWsGateway — default serverConfiguration branch', () => ), }; - const gateway = createLookoutWsGateway({ + const gateway = createPortwingWsGateway({ webSocketServer: mockWsServer as unknown as Parameters< - typeof createLookoutWsGateway + typeof createPortwingWsGateway >[0]['webSocketServer'], serverConfiguration: {}, // isRateLimited intentionally omitted — default () => false lambda should be invoked }); const socket = createMockSocket(); - const request = createRequest('/api/lookout/ws'); + const request = createRequest('/api/portwing/ws'); // handleUpgrade reaches the rate-limit check and calls () => false gateway.handleUpgrade(request, socket as unknown as Socket, Buffer.alloc(0)); @@ -1150,7 +1184,7 @@ describe('hello verification — helloHandled guard (post-hello messages ignored const { gateway, getUpgradedWs } = createGateway(record); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -1180,7 +1214,7 @@ describe('hello verification — missing data field', () => { test('parse-error when hello data is null', async () => { const { gateway, getUpgradedWs } = createGateway(); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -1216,7 +1250,7 @@ describe('hello verification — drydockCompat version warning', () => { const { gateway, getUpgradedWs } = createGateway(record); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -1261,7 +1295,7 @@ describe('hello verification — nonce cache full path', () => { const { gateway, getUpgradedWs } = createGateway(record); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -1299,7 +1333,7 @@ describe('hello verification — nonce cache full path', () => { const { gateway, getUpgradedWs } = createGateway(record); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -1337,7 +1371,7 @@ describe('hello verification — verifyHelloSignature throws (malformed pubkey)' const { gateway, getUpgradedWs } = createGateway(record); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -1376,7 +1410,7 @@ describe('hello verification — nonce admission rate limit', () => { const { gateway, getUpgradedWs } = createGateway(record); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -1448,9 +1482,9 @@ describe('hello verification — ws.send throws during welcome', () => { ), }; - const gateway = createLookoutWsGateway({ + const gateway = createPortwingWsGateway({ webSocketServer: mockWsServer as unknown as Parameters< - typeof createLookoutWsGateway + typeof createPortwingWsGateway >[0]['webSocketServer'], isRateLimited: () => false, serverConfiguration: {}, @@ -1458,7 +1492,7 @@ describe('hello verification — ws.send throws during welcome', () => { }); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -1471,7 +1505,7 @@ describe('hello verification — ws.send throws during welcome', () => { }); }); -describe('attachLookoutWsServer', () => { +describe('attachPortwingWsServer', () => { beforeEach(() => { vi.resetAllMocks(); clearNonceCacheForTesting(); @@ -1480,8 +1514,8 @@ describe('attachLookoutWsServer', () => { test('registers upgrade handler on server', () => { const onSpy = vi.fn(); const mockServer = { on: onSpy }; - const gateway = attachLookoutWsServer({ - server: mockServer as unknown as Parameters[0]['server'], + const gateway = attachPortwingWsServer({ + server: mockServer as unknown as Parameters[0]['server'], serverConfiguration: {}, }); expect(onSpy).toHaveBeenCalledWith('upgrade', expect.any(Function)); @@ -1497,15 +1531,15 @@ describe('attachLookoutWsServer', () => { }, }; - attachLookoutWsServer({ - server: mockServer as unknown as Parameters[0]['server'], + attachPortwingWsServer({ + server: mockServer as unknown as Parameters[0]['server'], serverConfiguration: {}, isRateLimited: () => true, }); // Invoke the registered upgrade handler with a request that should be rate-limited const socket = createMockSocket(); - const request = createRequest('/api/lookout/ws'); + const request = createRequest('/api/portwing/ws'); listeners.upgrade(request, socket, Buffer.alloc(0)); // Rate limited → 429 written to socket @@ -1516,8 +1550,8 @@ describe('attachLookoutWsServer', () => { // Exercises the `?? getServerConfiguration()` false branch on the nullish coalescing operator const onSpy = vi.fn(); const mockServer = { on: onSpy }; - const gateway = attachLookoutWsServer({ - server: mockServer as unknown as Parameters[0]['server'], + const gateway = attachPortwingWsServer({ + server: mockServer as unknown as Parameters[0]['server'], // serverConfiguration deliberately omitted → falls back to getServerConfiguration() }); expect(gateway).toBeDefined(); @@ -1525,7 +1559,7 @@ describe('attachLookoutWsServer', () => { }); }); -describe('createLookoutWsGateway — default isRateLimited branch', () => { +describe('createPortwingWsGateway — default isRateLimited branch', () => { beforeEach(() => { vi.resetAllMocks(); clearNonceCacheForTesting(); @@ -1541,9 +1575,9 @@ describe('createLookoutWsGateway — default isRateLimited branch', () => { ), }; // Omit isRateLimited to exercise the default () => false branch (line 207) - const gateway = createLookoutWsGateway({ + const gateway = createPortwingWsGateway({ webSocketServer: mockWsServer as unknown as Parameters< - typeof createLookoutWsGateway + typeof createPortwingWsGateway >[0]['webSocketServer'], serverConfiguration: {}, }); @@ -1551,7 +1585,7 @@ describe('createLookoutWsGateway — default isRateLimited branch', () => { const socket = createMockSocket(); // Request matches the route — upgrade is attempted, which invokes isRateLimited gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), socket as unknown as Socket, Buffer.alloc(0), ); @@ -1572,7 +1606,7 @@ describe('handleUpgrade — URL parse error path', () => { const socket = createMockSocket(); // A URL with a null byte causes new URL() to throw - const badRequest = createRequest('/api/lookout/ws\x00bad'); + const badRequest = createRequest('/api/portwing/ws\x00bad'); gateway.handleUpgrade(badRequest, socket as unknown as Socket, Buffer.alloc(0)); @@ -1627,7 +1661,7 @@ describe('hello verification — drydockCompat absent branch', () => { const { gateway, getUpgradedWs } = createGateway(record); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -1671,7 +1705,7 @@ describe('startNoncePruning — fresh nonce branch in interval callback', () => }); }); -describe('attachLookoutWsServer — default serverConfiguration branch', () => { +describe('attachPortwingWsServer — default serverConfiguration branch', () => { beforeEach(() => { vi.resetAllMocks(); clearNonceCacheForTesting(); @@ -1682,8 +1716,8 @@ describe('attachLookoutWsServer — default serverConfiguration branch', () => { const mockServer = { on: onSpy }; // Omit serverConfiguration to exercise the ?? branch (getServerConfiguration() fallback) - attachLookoutWsServer({ - server: mockServer as unknown as Parameters[0]['server'], + attachPortwingWsServer({ + server: mockServer as unknown as Parameters[0]['server'], }); expect(onSpy).toHaveBeenCalledWith('upgrade', expect.any(Function)); @@ -1706,7 +1740,7 @@ describe('handleConnection — hello timer cleanup on premature disconnect', () const { gateway, getUpgradedWs } = createGateway(); const socket = createMockSocket(); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), socket as unknown as Socket, Buffer.alloc(0), ); @@ -1725,7 +1759,7 @@ describe('handleConnection — hello timer cleanup on premature disconnect', () const { gateway, getUpgradedWs } = createGateway(); const socket = createMockSocket(); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), socket as unknown as Socket, Buffer.alloc(0), ); @@ -1766,7 +1800,7 @@ describe('hello verification — NaN/non-finite timestamp rejection', () => { const { gateway, getUpgradedWs } = createGateway(record); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -1828,7 +1862,7 @@ describe('hello verification — NaN/non-finite timestamp rejection', () => { const { gateway, getUpgradedWs } = createGateway(record); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -1866,7 +1900,7 @@ describe('hello verification — signature length cap', () => { const { gateway, getUpgradedWs } = createGateway(record); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -1898,7 +1932,7 @@ describe('hello verification — signature length cap', () => { const { gateway, getUpgradedWs } = createGateway(record); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -1942,7 +1976,7 @@ describe('disconnectByKeyId', () => { const { gateway, getUpgradedWs } = createGateway(record); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -1985,7 +2019,7 @@ describe('disconnectByKeyId', () => { const { gateway, getUpgradedWs } = createGateway(record); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -2013,7 +2047,7 @@ describe('disconnectByKeyId', () => { const { gateway, getUpgradedWs } = createGateway(record); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -2044,7 +2078,7 @@ describe('disconnectByKeyId', () => { const { gateway, getUpgradedWs } = createGateway(record); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -2074,7 +2108,7 @@ describe('disconnectByKeyId', () => { const { gateway, getUpgradedWs } = createGateway(record); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -2109,7 +2143,7 @@ describe('disconnectByKeyId', () => { const { gateway, getUpgradedWs } = createGateway(record); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -2144,7 +2178,7 @@ describe('disconnectByKeyId', () => { const sig1 = signHello(privateKey, ts1, nonce1); const { gateway: gw1, getUpgradedWs: getWs1 } = createGateway(record); gw1.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -2158,7 +2192,7 @@ describe('disconnectByKeyId', () => { const sig2 = signHello(privateKey, ts2, nonce2); const { gateway: gw2, getUpgradedWs: getWs2 } = createGateway(record); gw2.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); @@ -2191,7 +2225,7 @@ describe('disconnectByKeyId', () => { const { gateway, getUpgradedWs } = createGateway(record); gateway.handleUpgrade( - createRequest('/api/lookout/ws'), + createRequest('/api/portwing/ws'), createMockSocket() as unknown as Socket, Buffer.alloc(0), ); diff --git a/app/api/lookout-ws.ts b/app/api/portwing-ws.ts similarity index 93% rename from app/api/lookout-ws.ts rename to app/api/portwing-ws.ts index 87808bf5..fe1b029d 100644 --- a/app/api/lookout-ws.ts +++ b/app/api/portwing-ws.ts @@ -1,10 +1,10 @@ /** - * Lookout/1.0 WebSocket gateway. + * Portwing/1.0 WebSocket gateway. * - * Accepts edge agent connections at /api/lookout/ws (or /api/v1/lookout/ws). + * Accepts edge agent connections at /api/portwing/ws (or /api/v1/portwing/ws). * Auth is entirely in the first WS frame (Ed25519 hello); no session cookie required. * - * DrydockCompat 1.4.0 is the level advertised by the lookout client; the doc + * DrydockCompat 1.4.0 is the level advertised by the edge agent client; the doc * reference to 1.5.x reflects pre-release planning and has no bearing on the * wire protocol implemented here. */ @@ -30,21 +30,19 @@ import { writeUpgradeError, } from './ws-upgrade-utils.js'; -const log = logger.child({ component: 'lookout-ws' }); +const log = logger.child({ component: 'portwing-ws' }); -// Matches /api/lookout/ws and /api/v1/lookout/ws — the versioned alias is free. -// The lookout client dials cfg.DrydockURL + "/api/lookout/ws" so the primary -// match is the unversioned path. -export const LOOKOUT_WS_ROUTE_PATTERN = /^\/api(?:\/v1)?\/lookout\/ws$/; +// Matches canonical Portwing paths. +export const PORTWING_WS_ROUTE_PATTERN = /^\/api(?:\/v1)?\/portwing\/ws$/; -const PROTOCOL_STRING = 'lookout/1.0'; +const PROTOCOL_STRING = 'portwing/1.0'; const SERVER_COMPAT_LEVEL = '1.4'; const HELLO_TIMEOUT_MS = 30_000; const NONCE_PATTERN = /^[0-9a-f]{32}$/; // Key IDs are hex(SHA-256(raw32Bytes)[:8]) → exactly 16 lowercase hex chars. const KEY_ID_PATTERN = /^[0-9a-f]{16}$/; const MAX_CLOCK_SKEW_SECONDS = 60; -const MAX_PAYLOAD_BYTES = 16 * 1024 * 1024; // 16 MB — matches lookout conn.SetReadLimit +const MAX_PAYLOAD_BYTES = 16 * 1024 * 1024; // 16 MB — matches the agent conn.SetReadLimit // SHA-256 of empty string — the WebSocket upgrade has no body. const EMPTY_BODY_HASH = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; @@ -190,11 +188,10 @@ export function injectDrydockVersionForTesting(version: string): void { * Verify the Ed25519 signature in the hello frame. * Canonical message: METHOD\nPATH\nBODY_HASH_HEX\nUNIX_TIMESTAMP_DECIMAL\nNONCE * - * PATH is intentionally hard-coded to '/api/lookout/ws' regardless of whether the - * agent connected via the versioned alias '/api/v1/lookout/ws'. Both paths are - * signature-equivalent by design: the lookout client always signs over the canonical - * unversioned path so that adding or removing the /v1 alias never forces a key rotation. - * If this equivalence is ever removed, agents MUST be updated to sign the actual path. + * PATH is intentionally canonicalized to '/api/portwing/ws' regardless of + * whether the agent connected via the versioned alias '/api/v1/portwing/ws'. + * Both paths are signature-equivalent by design, so adding or removing the /v1 + * prefix never forces a key rotation. */ function verifyHelloSignature( pubkeyBase64: string, @@ -202,9 +199,6 @@ function verifyHelloSignature( nonce: string, signatureBase64url: string, ): boolean { - const canonical = Buffer.from( - ['GET', '/api/lookout/ws', EMPTY_BODY_HASH, String(timestamp), nonce].join('\n'), - ); // Registry stores raw 32-byte Ed25519 key as base64. Node.js crypto.verify // does not accept format:'raw' — reconstruct the full SPKI DER by prepending // the constant Ed25519 ASN.1 header before calling createPublicKey. @@ -213,10 +207,13 @@ function verifyHelloSignature( const pubKey = createPublicKey({ key: spkiDer, format: 'der', type: 'spki' }); const sigBuf = Buffer.from(signatureBase64url, 'base64url'); + const canonical = Buffer.from( + ['GET', '/api/portwing/ws', EMPTY_BODY_HASH, String(timestamp), nonce].join('\n'), + ); return cryptoVerify(null, canonical, pubKey, sigBuf); } -interface LookoutWsGatewayDependencies { +interface PortwingWsGatewayDependencies { webSocketServer?: { handleUpgrade: ( request: IncomingMessage, @@ -230,7 +227,7 @@ interface LookoutWsGatewayDependencies { getAgentKeys?: typeof agentKeys; } -export function createLookoutWsGateway(dependencies: LookoutWsGatewayDependencies = {}) { +export function createPortwingWsGateway(dependencies: PortwingWsGatewayDependencies = {}) { const { webSocketServer = new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD_BYTES }), /* v8 ignore next -- Buffer.from('…','base64') never throws; default lambda is defensive */ @@ -253,7 +250,7 @@ export function createLookoutWsGateway(dependencies: LookoutWsGatewayDependencie return; } /* v8 ignore stop */ - if (!LOOKOUT_WS_ROUTE_PATTERN.test(pathname)) { + if (!PORTWING_WS_ROUTE_PATTERN.test(pathname)) { return; } @@ -501,7 +498,7 @@ async function processHello( // Step 10: Prevent duplicate agent names — atomic check/reserve with inFlightAgents // so that concurrent hellos cannot both pass before either calls activate(). - const agentName = `lookout-edge-${hello.agentId}`; + const agentName = `portwing-edge-${hello.agentId}`; if (getAgent(agentName) || inFlightAgents.has(agentName)) { sendErrorAndClose(ws, 'agent-already-connected', `Agent ${agentName} already connected`, 1008); return; @@ -573,7 +570,7 @@ async function processHello( ); } -export function attachLookoutWsServer(options: { +export function attachPortwingWsServer(options: { server: { on: ( event: 'upgrade', @@ -586,7 +583,7 @@ export function attachLookoutWsServer(options: { const serverConfiguration = options.serverConfiguration ?? (getServerConfiguration() as Record); - const gateway = createLookoutWsGateway({ + const gateway = createPortwingWsGateway({ isRateLimited: options.isRateLimited, serverConfiguration, }); diff --git a/app/api/lookout.test.ts b/app/api/portwing.test.ts similarity index 97% rename from app/api/lookout.test.ts rename to app/api/portwing.test.ts index 7d00f7d1..cca9a8c8 100644 --- a/app/api/lookout.test.ts +++ b/app/api/portwing.test.ts @@ -1,5 +1,5 @@ /** - * Tests for the lookout pubkey management HTTP API. + * Tests for the Portwing pubkey management HTTP API. */ import { generateKeyPairSync } from 'node:crypto'; import { createMockRequest, createMockResponse } from '../test/helpers.js'; @@ -43,12 +43,12 @@ vi.mock('express', () => ({ vi.mock('../store/agent-keys.js', () => mockAgentKeys); vi.mock('./error-response.js', () => ({ sendErrorResponse: mockSendErrorResponse })); -vi.mock('./lookout-ws.js', () => ({ disconnectByKeyId: mockDisconnectByKeyId })); +vi.mock('./portwing-ws.js', () => ({ disconnectByKeyId: mockDisconnectByKeyId })); -import * as lookoutRouterModule from './lookout.js'; +import * as portwingRouterModule from './portwing.js'; // Initialize the router once to register all handlers and capture them -lookoutRouterModule.init(); +portwingRouterModule.init(); function generateEd25519RawPubkey(): Buffer { const { publicKey } = generateKeyPairSync('ed25519'); @@ -56,7 +56,7 @@ function generateEd25519RawPubkey(): Buffer { return spki.subarray(12); } -describe('Lookout Router — init', () => { +describe('Portwing Router — init', () => { test('registers GET /keys', () => { expect(capturedHandlers.getKeys).toBeDefined(); }); diff --git a/app/api/lookout.ts b/app/api/portwing.ts similarity index 90% rename from app/api/lookout.ts rename to app/api/portwing.ts index 78e1507d..8c8377db 100644 --- a/app/api/lookout.ts +++ b/app/api/portwing.ts @@ -1,14 +1,14 @@ /** - * Lookout pubkey management REST API. + * Portwing pubkey management REST API. * - * Mounted at /api/v1/lookout (and /api/lookout) AFTER requireAuthentication + * Mounted at /api/v1/portwing (and compatibility aliases) AFTER requireAuthentication * + requireSameOriginForMutations in api.ts. Operators use these routes to * manage which Ed25519 public keys are authorized for edge agent connections. */ import express, { type Request, type Response } from 'express'; import * as agentKeys from '../store/agent-keys.js'; import { sendErrorResponse } from './error-response.js'; -import { disconnectByKeyId } from './lookout-ws.js'; +import { disconnectByKeyId } from './portwing-ws.js'; // Key IDs are hex(SHA-256(raw32Bytes)[:8]) → exactly 16 lowercase hex chars. const KEY_ID_PATTERN = /^[0-9a-f]{16}$/; @@ -16,7 +16,7 @@ const KEY_ID_PATTERN = /^[0-9a-f]{16}$/; const router = express.Router(); /** - * GET /lookout/keys + * GET /portwing/keys * List all keys (active + revoked). */ router.get('/keys', (_req: Request, res: Response) => { @@ -25,7 +25,7 @@ router.get('/keys', (_req: Request, res: Response) => { }); /** - * POST /lookout/keys + * POST /portwing/keys * Add a new authorized key. * Body: { pubkeyBase64: string, label: string } * Returns 201 { keyId, label, createdAt } @@ -83,7 +83,7 @@ router.post('/keys', (req: Request, res: Response) => { }); /** - * DELETE /lookout/keys/:keyId + * DELETE /portwing/keys/:keyId * Revoke a key. Returns 204 on success, 404 if not found. */ router.delete('/keys/:keyId', (req: Request, res: Response) => { @@ -102,7 +102,7 @@ router.delete('/keys/:keyId', (req: Request, res: Response) => { }); /** - * Init the lookout router. + * Init the Portwing router. */ export function init(): express.Router { return router; diff --git a/app/configuration/index.test.ts b/app/configuration/index.test.ts index 38d7c5cd..f178577d 100644 --- a/app/configuration/index.test.ts +++ b/app/configuration/index.test.ts @@ -86,33 +86,33 @@ test('getLocalWatcherEnabled should return false when disabled via env', async ( delete configuration.ddEnvVars.DD_LOCAL_WATCHER; }); -test('getExperimentalLookoutEnabled should default to false', () => { - delete configuration.ddEnvVars.DD_EXPERIMENTAL_LOOKOUT; - expect(configuration.getExperimentalLookoutEnabled()).toStrictEqual(false); +test('getExperimentalPortwingEnabled should default to false', () => { + delete configuration.ddEnvVars.DD_EXPERIMENTAL_PORTWING; + expect(configuration.getExperimentalPortwingEnabled()).toStrictEqual(false); }); -test('getExperimentalLookoutEnabled should return true when set to "true"', () => { - configuration.ddEnvVars.DD_EXPERIMENTAL_LOOKOUT = 'true'; - expect(configuration.getExperimentalLookoutEnabled()).toStrictEqual(true); - delete configuration.ddEnvVars.DD_EXPERIMENTAL_LOOKOUT; +test('getExperimentalPortwingEnabled should return true when set to "true"', () => { + configuration.ddEnvVars.DD_EXPERIMENTAL_PORTWING = 'true'; + expect(configuration.getExperimentalPortwingEnabled()).toStrictEqual(true); + delete configuration.ddEnvVars.DD_EXPERIMENTAL_PORTWING; }); -test('getExperimentalLookoutEnabled should normalize casing', () => { - configuration.ddEnvVars.DD_EXPERIMENTAL_LOOKOUT = 'TRUE'; - expect(configuration.getExperimentalLookoutEnabled()).toStrictEqual(true); - delete configuration.ddEnvVars.DD_EXPERIMENTAL_LOOKOUT; +test('getExperimentalPortwingEnabled should normalize casing', () => { + configuration.ddEnvVars.DD_EXPERIMENTAL_PORTWING = 'TRUE'; + expect(configuration.getExperimentalPortwingEnabled()).toStrictEqual(true); + delete configuration.ddEnvVars.DD_EXPERIMENTAL_PORTWING; }); -test('getExperimentalLookoutEnabled should trim whitespace before comparing', () => { - configuration.ddEnvVars.DD_EXPERIMENTAL_LOOKOUT = ' true '; - expect(configuration.getExperimentalLookoutEnabled()).toStrictEqual(true); - delete configuration.ddEnvVars.DD_EXPERIMENTAL_LOOKOUT; +test('getExperimentalPortwingEnabled should trim whitespace before comparing', () => { + configuration.ddEnvVars.DD_EXPERIMENTAL_PORTWING = ' true '; + expect(configuration.getExperimentalPortwingEnabled()).toStrictEqual(true); + delete configuration.ddEnvVars.DD_EXPERIMENTAL_PORTWING; }); -test('getExperimentalLookoutEnabled should return false for non-"true" values', () => { - configuration.ddEnvVars.DD_EXPERIMENTAL_LOOKOUT = '1'; - expect(configuration.getExperimentalLookoutEnabled()).toStrictEqual(false); - delete configuration.ddEnvVars.DD_EXPERIMENTAL_LOOKOUT; +test('getExperimentalPortwingEnabled should return false for non-"true" values', () => { + configuration.ddEnvVars.DD_EXPERIMENTAL_PORTWING = '1'; + expect(configuration.getExperimentalPortwingEnabled()).toStrictEqual(false); + delete configuration.ddEnvVars.DD_EXPERIMENTAL_PORTWING; }); test('getDnsMode should default to ipv4first', () => { diff --git a/app/configuration/index.ts b/app/configuration/index.ts index d46a1020..28649a2c 100644 --- a/app/configuration/index.ts +++ b/app/configuration/index.ts @@ -173,8 +173,12 @@ export function getLocalWatcherEnabled() { return ddEnvVars.DD_LOCAL_WATCHER?.trim().toLowerCase() !== 'false'; } -export function getExperimentalLookoutEnabled() { - return ddEnvVars.DD_EXPERIMENTAL_LOOKOUT?.trim().toLowerCase() === 'true'; +function envFlagEnabled(value: string | undefined) { + return value?.trim().toLowerCase() === 'true'; +} + +export function getExperimentalPortwingEnabled() { + return envFlagEnabled(ddEnvVars.DD_EXPERIMENTAL_PORTWING); } function parseWatcherMaintenanceEnvAlias(envKey: string) { diff --git a/app/store/agent-keys.test.ts b/app/store/agent-keys.test.ts index 99698db8..475a897a 100644 --- a/app/store/agent-keys.test.ts +++ b/app/store/agent-keys.test.ts @@ -297,7 +297,7 @@ describe('listKeys', () => { }); describe('keyId derivation (golden test)', () => { - test('matches lookout hex(SHA-256[:8]) formula', () => { + test('matches Portwing hex(SHA-256[:8]) formula', () => { const rawKey = generateEd25519RawPublicKey(); const expected = createHash('sha256').update(rawKey).digest().subarray(0, 8).toString('hex'); diff --git a/app/store/agent-keys.ts b/app/store/agent-keys.ts index 0ce58d81..1a586757 100644 --- a/app/store/agent-keys.ts +++ b/app/store/agent-keys.ts @@ -1,6 +1,6 @@ /** * Agent key registry store. - * Tracks Ed25519 public keys that are authorized to connect via the lookout/1.0 + * Tracks Ed25519 public keys that are authorized to connect via the portwing/1.0 * WebSocket protocol. One document per key; active keys have revokedAt === null. */ import { createHash } from 'node:crypto'; @@ -34,7 +34,7 @@ let agentKeyCollection: AgentKeyCollection | undefined; /** * Derive the 16-char hex key ID from a raw 32-byte Ed25519 public key buffer. - * Matches lookout's derivation: hex(SHA-256(raw32Bytes)[:8]) + * Matches Portwing's derivation: hex(SHA-256(raw32Bytes)[:8]) */ function deriveKeyId(pubkeyBuffer: Buffer): string { return createHash('sha256').update(pubkeyBuffer).digest().subarray(0, 8).toString('hex'); diff --git a/content/docs/current/api/agent.mdx b/content/docs/current/api/agent.mdx index 009bc387..9f9a1252 100644 --- a/content/docs/current/api/agent.mdx +++ b/content/docs/current/api/agent.mdx @@ -3,7 +3,7 @@ title: "Agent API" description: "Controller endpoints for remote agent status and logs, plus the Agent's own internal API." --- -For agents that cannot accept inbound connections and dial out to drydock instead, see the [Lookout Edge API](/docs/api/lookout). +For agents that cannot accept inbound connections and dial out to drydock instead, see the [Portwing Edge API](/docs/api/portwing). ## Controller endpoints @@ -160,7 +160,7 @@ Sent immediately upon connection to confirm the handshake. } ``` -The `logLevel` and `pollInterval` fields correspond to the same-named fields in the [GET /agents](#list-all-agents) response. For edge agents connecting via the lookout protocol see the [Lookout Edge API](/docs/api/lookout). +The `logLevel` and `pollInterval` fields correspond to the same-named fields in the [GET /agents](#list-all-agents) response. For edge agents connecting via the Portwing protocol, see the [Portwing Edge API](/docs/api/portwing). #### `dd:container-added` diff --git a/content/docs/current/api/meta.json b/content/docs/current/api/meta.json index f309d0b4..a9eba8c6 100644 --- a/content/docs/current/api/meta.json +++ b/content/docs/current/api/meta.json @@ -3,7 +3,7 @@ "pages": [ "index", "agent", - "lookout", + "portwing", "app", "container", "log", diff --git a/content/docs/current/api/lookout.mdx b/content/docs/current/api/portwing.mdx similarity index 78% rename from content/docs/current/api/lookout.mdx rename to content/docs/current/api/portwing.mdx index 9899cdae..5fc80e1e 100644 --- a/content/docs/current/api/lookout.mdx +++ b/content/docs/current/api/portwing.mdx @@ -1,11 +1,11 @@ --- -title: "Lookout Edge API" -description: "Ed25519 agent-key registry and the lookout/1.0 WebSocket edge endpoint for firewall-traversing agents." +title: "Portwing Edge API" +description: "Ed25519 agent-key registry and the portwing/1.0 WebSocket edge endpoint for firewall-traversing agents." --- -> **Experimental feature.** The Lookout edge endpoint must be explicitly enabled with the environment variable `DD_EXPERIMENTAL_LOOKOUT=true` on the drydock server. When this flag is absent or set to any other value the `/api/v1/lookout/*` routes (both REST and WebSocket) are **not mounted at all**. The wire protocol may change between releases while the feature is experimental. +> **Experimental feature.** The Portwing edge endpoint must be explicitly enabled with the environment variable `DD_EXPERIMENTAL_PORTWING=true` on the drydock server. When the feature is disabled, the `/api/v1/portwing/*` routes (both REST and WebSocket) are **not mounted at all**. The wire protocol may change between releases while the feature is experimental. -Edge mode inverts the normal drydock connection model. In the standard setup the controller dials out to each remote agent. Edge mode is for agents that sit behind NAT or a firewall and cannot accept inbound connections: the `lookout` agent dials **out** to drydock over an encrypted WebSocket (`wss://`), and drydock registers the connection as if the agent had been reached normally. No inbound port on the agent host is needed. The chain is `sockguard → lookout (edge client) → drydock (this endpoint)`. +Edge mode inverts the normal drydock connection model. In the standard setup the controller dials out to each remote agent. Edge mode is for agents that sit behind NAT or a firewall and cannot accept inbound connections: the Portwing agent dials **out** to drydock over an encrypted WebSocket (`wss://`), and drydock registers the connection as if the agent had been reached normally. No inbound port on the agent host is needed. The chain is `sockguard -> portwing (edge client) -> drydock (this endpoint)`. > For agents that accept inbound connections from the controller, see the [Agent API](/docs/api/agent). @@ -25,13 +25,13 @@ The signature is computed over a canonical, newline-joined message (SigV4-style) ```text GET -/api/lookout/ws +/api/portwing/ws ``` -The body hash is the SHA-256 of an empty body (`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`), and the path segment is always the literal `/api/lookout/ws` — both the versioned path `/api/v1/lookout/ws` and the deprecated unversioned path `/api/lookout/ws` are signature-equivalent by design, so that adding or removing the `/v1` prefix never requires a key rotation. +The body hash is the SHA-256 of an empty body (`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`), and the Portwing path segment is always the literal `/api/portwing/ws` — both the versioned path `/api/v1/portwing/ws` and the deprecated unversioned path `/api/portwing/ws` are signature-equivalent by design, so that adding or removing the `/v1` prefix never requires a key rotation. **Encoding note.** The `signature` field is **base64url** (RFC 4648 §5 — URL-safe alphabet, no padding). An Ed25519 signature is 64 bytes, which encodes to exactly 86 base64url characters. The public key stored in the registry (`pubkey` field) uses **standard base64** (44 characters for a 32-byte key). These two encodings are distinct — agents must use base64url for the signature and standard base64 for the public-key submission. @@ -39,16 +39,16 @@ The body hash is the SHA-256 of an empty body (`e3b0c44298fc1c149afbf4c8996fb924 ## Key registry -The key registry is mounted at `/api/v1/lookout/keys`. All routes require an authenticated drydock session (same-origin or equivalent). +The key registry is mounted at `/api/v1/portwing/keys`. All routes require an authenticated drydock session (same-origin or equivalent). -> The unversioned path `/api/lookout/keys` is a deprecated alias that will be removed in **v1.6.0**. Third-party integrations should migrate to `/api/v1/lookout/keys`. +> The unversioned path `/api/portwing/keys` is a deprecated alias that will be removed in **v1.6.0**. Third-party integrations should migrate to `/api/v1/portwing/keys`. ### List keys Returns all registered keys — both active and revoked. ```bash -curl http://drydock:3000/api/v1/lookout/keys +curl http://drydock:3000/api/v1/portwing/keys [ { @@ -74,7 +74,7 @@ Each record has the shape: ### Register a key ```bash -curl -X POST http://drydock:3000/api/v1/lookout/keys \ +curl -X POST http://drydock:3000/api/v1/portwing/keys \ -H "Content-Type: application/json" \ -d '{ "pubkeyBase64": "kPGd2xqT8vWzN4cRfYbLmJhQ5sUaE7nVwXyZ0AB1CdE=", @@ -110,7 +110,7 @@ HTTP/1.1 201 Created Revoked keys are rejected on the next connection attempt. The record is retained (with `revokedAt` set) for audit purposes. Any live WebSocket sessions authenticated under the revoked key are disconnected immediately. ```bash -curl -X DELETE http://drydock:3000/api/v1/lookout/keys/3f8a1c2e9b047d56 +curl -X DELETE http://drydock:3000/api/v1/portwing/keys/3f8a1c2e9b047d56 HTTP/1.1 204 No Content ``` @@ -129,16 +129,16 @@ HTTP/1.1 204 No Content Edge agents connect to the **versioned** path: ```text -wss://drydock/api/v1/lookout/ws +wss://drydock/api/v1/portwing/ws ``` -The unversioned path `/api/lookout/ws` is also accepted and is signature-equivalent — it is a deprecated alias that will be removed in **v1.6.0**. The canonical path `/api/lookout/ws` is always used in signature computation regardless of which path the agent dialed, so migrating from the unversioned to the versioned path does not require a key rotation. +The unversioned path `/api/portwing/ws` is also accepted and is signature-equivalent — it is a deprecated alias that will be removed in **v1.6.0**. The canonical path `/api/portwing/ws` is always used in signature computation regardless of which path the agent dialed, so migrating from the unversioned to the versioned path does not require a key rotation. -**Subprotocol:** `lookout/1.0` +**Subprotocol:** `portwing/1.0` ### Connection flow -1. The agent opens a WebSocket upgrade to `wss://drydock/api/v1/lookout/ws` with the `lookout/1.0` subprotocol header. +1. The agent opens a WebSocket upgrade to `wss://drydock/api/v1/portwing/ws` with the `portwing/1.0` subprotocol header. 2. The agent sends a **hello frame** as the first message (within 30 seconds, otherwise the connection is closed). The frame carries the `pubKeyId`, timestamp, nonce, and Ed25519 signature described under [Ed25519 authentication model](#ed25519-authentication-model) above. 3. drydock verifies the hello. On success it replies with a **welcome frame** carrying poll configuration and server version information. 4. The connection is then live: the agent multiplexes container-sync and exec frames over the same WebSocket. @@ -150,7 +150,7 @@ The hello frame is a flat JSON object sent as the first WebSocket message after ```json { "type": "hello", - "protocol": "lookout/1.0", + "protocol": "portwing/1.0", "agentId": "us-east-prod-1", "agentName": "us-east-prod-1", "version": "1.5.0", @@ -170,7 +170,7 @@ The hello frame is a flat JSON object sent as the first WebSocket message after | Field | Type | Required | Description | | --- | --- | --- | --- | | `type` | string | Yes | Always `"hello"` | -| `protocol` | string | Yes | Must be `"lookout/1.0"` | +| `protocol` | string | Yes | Must be `"portwing/1.0"` | | `agentId` | string | Yes | Agent identifier (alphanumeric, hyphens, underscores; max 64 chars) | | `agentName` | string | Yes | Human-readable agent name | | `version` | string | Yes | Agent drydock version string | @@ -197,7 +197,7 @@ On a successful hello, drydock replies with a welcome frame: "pollInterval": 300, "config": { "drydockVersion": "1.5.0", - "supportedProtocols": "lookout/1.0", + "supportedProtocols": "portwing/1.0", "serverCompatLevel": "1.4" } } @@ -208,7 +208,7 @@ On a successful hello, drydock replies with a welcome frame: | --- | --- | --- | | `data.pollInterval` | number | Recommended container-sync poll interval in seconds (currently `300`) | | `data.config.drydockVersion` | string | Server drydock version | -| `data.config.supportedProtocols` | string | Protocol string accepted by this server (`"lookout/1.0"`) | +| `data.config.supportedProtocols` | string | Canonical protocol string advertised by this server (`"portwing/1.0"`) | | `data.config.serverCompatLevel` | string | Server compat level (`"1.4"`) | ### Protocol limits @@ -244,7 +244,7 @@ If the hello is rejected, drydock sends a JSON error frame before closing: | `expected-hello` | First frame was not `type: "hello"` | Send the hello frame immediately after upgrade | | `no-auth` | Hello frame contains neither Ed25519 fields nor a `tokenHash` | Include `pubKeyId`, `timestamp`, `nonce`, and `signature` | | `ed25519-required` | Hello supplied only `tokenHash` (no Ed25519 fields) | Include a registered Ed25519 key | -| `protocol-mismatch` | `protocol` field is not `"lookout/1.0"` | Upgrade the agent or check the configured protocol string | +| `protocol-mismatch` | `protocol` field is not `"portwing/1.0"` | Upgrade the agent or check the configured protocol string | | `unknown-key` | `pubKeyId` is not registered, has been revoked, or does not match the 16 hex-char format | Re-register a valid key; also fired when an active session is disconnected due to key revocation | | `timestamp-skew` | `timestamp` is more than 60 seconds from server time | Sync the agent clock (NTP); check for time-zone misconfiguration | | `bad-nonce` | `nonce` is not exactly 32 lowercase hex characters | Generate a cryptographically random 16-byte value and hex-encode it | @@ -256,15 +256,15 @@ If the hello is rejected, drydock sends a JSON error frame before closing: --- -## Enrolling a lookout agent +## Enrolling a Portwing agent 1. **Enable the experimental feature** on the drydock server: ```bash - DD_EXPERIMENTAL_LOOKOUT=true + DD_EXPERIMENTAL_PORTWING=true ``` - Without this flag, the `/api/v1/lookout/*` routes are not mounted and connections will be refused at the HTTP layer. + Without this flag, the `/api/v1/portwing/*` routes are not mounted and connections will be refused at the HTTP layer. 2. **Generate a keypair** on the agent host: @@ -275,12 +275,12 @@ If the hello is rejected, drydock sends a JSON error frame before closing: The last command prints the 44-character standard-base64 public key to register. -3. **Register the public key** with drydock using `POST /api/v1/lookout/keys`. Save the returned `keyId` — the agent needs it in its configuration. +3. **Register the public key** with drydock using `POST /api/v1/portwing/keys`. Save the returned `keyId` — the agent needs it in its configuration. -4. **Configure and start the agent.** Point `lookout` at the versioned endpoint: +4. **Configure and start the agent.** Point Portwing at the versioned endpoint: ```text - wss://drydock/api/v1/lookout/ws + wss://drydock/api/v1/portwing/ws ``` and supply the private key file and `keyId`. The agent will dial out and authenticate automatically. From f6e1f275b276a6b4cb6627a98806f0d0fd69eae1 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 15 Jun 2026 08:19:48 -0400 Subject: [PATCH 02/11] =?UTF-8?q?=F0=9F=90=9B=20fix(docs):=20strip=20HTML?= =?UTF-8?q?=20comments=20from=20generated=20changelog=20MDX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync-docs.mjs copied CHANGELOG.md verbatim into the fumadocs MDX source. The maintainer note in the [Unreleased] section is valid in the GitHub-rendered CHANGELOG but MDX v3 rejects HTML comments, failing the Turbopack build (drydock-website production + preview deploys erroring since 2026-06-13). Strip HTML comments during generation (they are not published-docs content) and collapse the resulting blank-line gap. CHANGELOG.md stays GitHub-valid and unchanged. --- apps/web/scripts/sync-docs.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/scripts/sync-docs.mjs b/apps/web/scripts/sync-docs.mjs index 582986cf..c26be223 100644 --- a/apps/web/scripts/sync-docs.mjs +++ b/apps/web/scripts/sync-docs.mjs @@ -27,7 +27,15 @@ title: "Changelog" description: "All notable changes to this project will be documented in this file." ---`; -const body = changelogMd.replace(/^# Changelog\n/, ""); +// Strip HTML comments () before emitting MDX. They are valid in the +// GitHub-rendered CHANGELOG (used for maintainer-only notes) but MDX v3 rejects +// them ("use {/* */}"), which fails the fumadocs/Turbopack build. They are not +// published-docs content, so drop them, then collapse the blank-line gap left +// behind so the generated MDX stays tidy. +const body = changelogMd + .replace(/^# Changelog\n/, "") + .replace(//g, "") + .replace(/\n{3,}/g, "\n\n"); // Write changelog into the current (v1.5) source dir so it gets copied const changelogDir = join(repoRoot, "content", "docs", "current", "changelog"); From 6335a0adb0ff97e1ab03a6eeea36e3842e73636a Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 15 Jun 2026 08:44:15 -0400 Subject: [PATCH 03/11] =?UTF-8?q?=F0=9F=90=9B=20fix(tag):=20pin=20specific?= =?UTF-8?q?-precision=20tags=20to=20digest-only=20by=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #321 (Fix 1): containers tagged with a fully-specified semver (≥3 segments, tagPrecision='specific') were silently climbing to newer versions. Gate getTagCandidates to return an empty tag list (digest-only path) for 'specific' tags unless the user opts in via dd.tag.include or dd.tag.family=loose. --- .../providers/docker/tag-candidates.test.ts | 123 ++++++++++++++++++ .../providers/docker/tag-candidates.ts | 17 +++ 2 files changed, 140 insertions(+) diff --git a/app/watchers/providers/docker/tag-candidates.test.ts b/app/watchers/providers/docker/tag-candidates.test.ts index 517ee505..720e7d06 100644 --- a/app/watchers/providers/docker/tag-candidates.test.ts +++ b/app/watchers/providers/docker/tag-candidates.test.ts @@ -550,6 +550,129 @@ describe('docker tag candidates module', () => { expect(result.tags).toEqual(['v2.0.0', 'v1.2.0', 'v1.1.3', 'v1.0.5', 'v1.0.2', 'v1.0.1']); }); + describe('specific-tag pin gate', () => { + test('specific v1.13.3, no labels → digest-only, no semver climb', () => { + const container = createContainer({ + image: { + tag: { + value: 'v1.13.3', + semver: true, + tagPrecision: 'specific', + }, + }, + tagFamily: 'strict', + }); + const log = { warn: vi.fn(), debug: vi.fn() }; + + const result = getTagCandidates( + container, + ['v1.13.3', 'v1.13.4', 'v1.14.0', 'v1.46.1', 'v2.0.0'], + log, + ); + + expect(result.tags).toEqual([]); + expect(result.noUpdateReason).toContain('Pinned tag'); + expect(log.debug).toHaveBeenCalledWith(expect.stringContaining('Pinned tag')); + }); + + test('specific v1.13.3 + dd.tag.include → climbs within filter, not beyond', () => { + const container = createContainer({ + image: { + tag: { + value: 'v1.13.3', + semver: true, + tagPrecision: 'specific', + }, + }, + includeTags: '^v1\\.13\\.', + tagFamily: 'strict', + }); + const log = { warn: vi.fn(), debug: vi.fn() }; + + const result = getTagCandidates(container, ['v1.13.3', 'v1.13.4', 'v1.14.0', 'v1.46.1'], log); + + expect(result.tags).toContain('v1.13.4'); + expect(result.tags).not.toContain('v1.14.0'); + expect(result.tags).not.toContain('v1.46.1'); + }); + + test('specific v1.13.3 + dd.tag.family=loose → semver climb allowed', () => { + const container = createContainer({ + image: { + tag: { + value: 'v1.13.3', + semver: true, + tagPrecision: 'specific', + }, + }, + tagFamily: 'loose', + }); + const log = { warn: vi.fn(), debug: vi.fn() }; + + const result = getTagCandidates(container, ['v1.13.3', 'v1.13.4', 'v1.14.0', 'v1.46.1'], log); + + expect(result.tags).toContain('v1.46.1'); + }); + + test('floating latest with no labels → unchanged (floating gate fires, not specific gate)', () => { + const container = createContainer({ + image: { + tag: { + value: 'latest', + semver: true, + tagPrecision: 'floating', + }, + }, + tagFamily: 'strict', + }); + const log = { warn: vi.fn(), debug: vi.fn() }; + + const result = getTagCandidates(container, ['latest', '1.0.0', '2.0.0'], log); + + expect(result.tags).toEqual([]); + expect(result.noUpdateReason).toContain('Floating tag alias'); + }); + + test('specific CalVer 2026.3.0, no labels → pinned, no semver climb', () => { + const container = createContainer({ + image: { + tag: { + value: '2026.3.0', + semver: true, + tagPrecision: 'specific', + }, + }, + tagFamily: 'strict', + }); + const log = { warn: vi.fn(), debug: vi.fn() }; + + const result = getTagCandidates(container, ['2026.3.0', '2026.4.0', '2027.1.0'], log); + + expect(result.tags).toEqual([]); + expect(result.noUpdateReason).toContain('Pinned tag'); + }); + + test('specific pin with digest.watch=true → noUpdateReason is undefined', () => { + const container = createContainer({ + image: { + tag: { + value: 'v1.13.3', + semver: true, + tagPrecision: 'specific', + }, + digest: { watch: true }, + }, + tagFamily: 'strict', + }); + const log = { warn: vi.fn(), debug: vi.fn() }; + + const result = getTagCandidates(container, ['v1.13.3', 'v1.13.4', 'v1.46.1'], log); + + expect(result.tags).toEqual([]); + expect(result.noUpdateReason).toBeUndefined(); + }); + }); + test('processes large tag lists within lightweight runtime budget', () => { const container = createContainer({ image: { diff --git a/app/watchers/providers/docker/tag-candidates.ts b/app/watchers/providers/docker/tag-candidates.ts index a416ea46..fe9b752b 100644 --- a/app/watchers/providers/docker/tag-candidates.ts +++ b/app/watchers/providers/docker/tag-candidates.ts @@ -453,6 +453,23 @@ export function getTagCandidates( }; } + // A 'specific' (fully-pinned) tag is digest-only by default. + // Opt out with dd.tag.include filter OR dd.tag.family=loose. + if ( + container.image.tag.tagPrecision === 'specific' && + !container.includeTags && + tagFamilyPolicy !== 'loose' + ) { + const noUpdateReason = `Pinned tag "${container.image.tag.value}" is compared by digest only. Set dd.tag.family=loose or add a dd.tag.include filter to allow semver version climbing.`; + if (typeof logContainer?.debug === 'function') { + logContainer.debug(noUpdateReason); + } + return { + tags: [], + noUpdateReason: container.image.digest?.watch ? undefined : noUpdateReason, + }; + } + if (filteredTags.length === 0) { logContainer.warn('No tags found after filtering; check you regex filters'); } From 1bdfccb029cbaa074e6e29ef293bc12d5c53f1e4 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 15 Jun 2026 08:52:00 -0400 Subject: [PATCH 04/11] =?UTF-8?q?=F0=9F=90=9B=20fix(docker):=20maintenance?= =?UTF-8?q?=20window=20now=20gates=20auto-update=20execution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #321 (Fix 2): auto-triggered container updates were applied outside the configured maintenance window. - Gate maybeFastResyncAfterUpdate with the same window check used in watchFromCron — debug-log and return early when the window is closed - Add maintenance-window-closed UpdateBlocker (soft, auto-updates only) with maintenanceWindowOpen context field in computeUpdateEligibility; manual UI/API updates remain ungated - Update HARD_BLOCKER_STATUS in request-update.ts to keep exhaustive map - Mirror UpdateBlockerReason + BLOCKER_SEVERITY in UI types and utils --- app/model/update-eligibility.test.ts | 83 ++++++++++++++++++++ app/model/update-eligibility.ts | 23 +++++- app/updates/request-update.ts | 2 + app/watchers/providers/docker/Docker.test.ts | 62 +++++++++++++++ app/watchers/providers/docker/Docker.ts | 8 ++ ui/src/types/container.d.ts | 3 +- ui/src/utils/container-mapper.ts | 1 + ui/src/utils/update-eligibility.ts | 2 + ui/tests/utils/container-mapper.spec.ts | 1 + ui/tests/utils/update-eligibility.spec.ts | 1 + 10 files changed, 184 insertions(+), 2 deletions(-) diff --git a/app/model/update-eligibility.test.ts b/app/model/update-eligibility.test.ts index ef452071..f7d0d701 100644 --- a/app/model/update-eligibility.test.ts +++ b/app/model/update-eligibility.test.ts @@ -1150,6 +1150,7 @@ describe('computeUpdateEligibility', () => { 'trigger-not-included', 'agent-mismatch', 'no-update-trigger-configured', + 'maintenance-window-closed', ] as const; for (const reason of allReasons) { expect(BLOCKER_SEVERITY[reason]).toMatch(/^(hard|soft)$/); @@ -1387,3 +1388,85 @@ describe('computeUpdateEligibility — self-update-unavailable', () => { expect(result.blockers.some((b) => b.reason === 'self-update-unavailable')).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// maintenance-window-closed blocker +// --------------------------------------------------------------------------- + +describe('computeUpdateEligibility — maintenance-window-closed', () => { + test('emits blocker when maintenanceWindowOpen is false (auto-update path)', () => { + const trigger = makeTrigger(); + const container = makeContainerWithTagUpdate(); + const result = computeUpdateEligibility( + container, + makeContext({ + triggers: { 'docker.update': trigger as never }, + maintenanceWindowOpen: false, + now: FIXED_NOW, + }), + ); + const blocker = result.blockers.find((b) => b.reason === 'maintenance-window-closed'); + expect(blocker).toBeDefined(); + expect(blocker?.actionable).toBe(false); + expect(blocker?.message).toContain('maintenance window'); + expect(result.eligible).toBe(false); + }); + + test('blocker has severity "soft" — manual updates are not blocked', () => { + expect(BLOCKER_SEVERITY['maintenance-window-closed']).toBe('soft'); + const trigger = makeTrigger(); + const container = makeContainerWithTagUpdate(); + const result = computeUpdateEligibility( + container, + makeContext({ + triggers: { 'docker.update': trigger as never }, + maintenanceWindowOpen: false, + now: FIXED_NOW, + }), + ); + const blocker = result.blockers.find((b) => b.reason === 'maintenance-window-closed'); + expect(blocker?.severity).toBe('soft'); + }); + + test('no blocker when maintenanceWindowOpen is true', () => { + const trigger = makeTrigger(); + const container = makeContainerWithTagUpdate(); + const result = computeUpdateEligibility( + container, + makeContext({ + triggers: { 'docker.update': trigger as never }, + maintenanceWindowOpen: true, + now: FIXED_NOW, + }), + ); + expect(result.blockers.find((b) => b.reason === 'maintenance-window-closed')).toBeUndefined(); + expect(result.eligible).toBe(true); + }); + + test('no blocker when maintenanceWindowOpen is undefined (manual update path — fail open)', () => { + const trigger = makeTrigger(); + const container = makeContainerWithTagUpdate(); + const result = computeUpdateEligibility( + container, + makeContext({ + triggers: { 'docker.update': trigger as never }, + maintenanceWindowOpen: undefined, + now: FIXED_NOW, + }), + ); + expect(result.blockers.find((b) => b.reason === 'maintenance-window-closed')).toBeUndefined(); + expect(result.eligible).toBe(true); + }); + + test('maintenance-window-closed does not appear when no update exists (short-circuit)', () => { + // no-update-available short-circuit fires first + const container = makeContainer(); + const result = computeUpdateEligibility( + container, + makeContext({ maintenanceWindowOpen: false, now: FIXED_NOW }), + ); + expect(result.blockers).toHaveLength(1); + expect(result.blockers[0].reason).toBe('no-update-available'); + expect(result.blockers.find((b) => b.reason === 'maintenance-window-closed')).toBeUndefined(); + }); +}); diff --git a/app/model/update-eligibility.ts b/app/model/update-eligibility.ts index 4868546e..db9a3812 100644 --- a/app/model/update-eligibility.ts +++ b/app/model/update-eligibility.ts @@ -20,7 +20,8 @@ export type UpdateBlockerReason = | 'trigger-not-included' | 'agent-mismatch' | 'no-update-trigger-configured' - | 'self-update-unavailable'; + | 'self-update-unavailable' + | 'maintenance-window-closed'; /** * Severity controls how a blocker is enforced: @@ -50,6 +51,8 @@ export const BLOCKER_SEVERITY: Record { id: string; status: 'queued' | 'in-progress'; updatedAt?: string } | undefined; now?: number; isSelfUpdateAvailable?: boolean; + /** + * Set to `false` by auto-trigger dispatch to block updates outside the maintenance window. + * When `undefined` (the default for manual API/UI requests) the check is skipped so that + * operators can always force an update regardless of the window. + */ + maintenanceWindowOpen?: boolean; } /** @@ -190,6 +199,18 @@ export function computeUpdateEligibility( const blockers: UpdateBlocker[] = []; + // maintenance-window-closed: fires only when the caller explicitly passes `false` (auto-trigger + // dispatch). Manual API/UI callers pass `undefined` → no check → window never blocks manual ops. + if (context.maintenanceWindowOpen === false) { + blockers.push( + makeBlocker({ + reason: 'maintenance-window-closed', + message: 'Outside maintenance window — auto update deferred until the window opens.', + actionable: false, + }), + ); + } + // 0. self-update-unavailable — fires only when the container is the Drydock self-container // AND the caller has explicitly determined that self-update cannot run (isSelfUpdateAvailable // === false). When the field is undefined we fail-open (do not block). diff --git a/app/updates/request-update.ts b/app/updates/request-update.ts index 4938dd46..37247333 100644 --- a/app/updates/request-update.ts +++ b/app/updates/request-update.ts @@ -166,6 +166,8 @@ const HARD_BLOCKER_STATUS: Record = { 'threshold-not-reached': 409, 'trigger-excluded': 409, 'trigger-not-included': 409, + // soft — manual callers do not reach this code path but the map must be exhaustive + 'maintenance-window-closed': 409, }; function statusCodeForHardBlocker(blocker: UpdateBlocker): number { diff --git a/app/watchers/providers/docker/Docker.test.ts b/app/watchers/providers/docker/Docker.test.ts index f866eb9c..c44c9f78 100644 --- a/app/watchers/providers/docker/Docker.test.ts +++ b/app/watchers/providers/docker/Docker.test.ts @@ -879,6 +879,68 @@ describe('Docker Watcher', () => { const watchOrder = docker.watchContainer.mock.invocationCallOrder[0]; expect(markOrder).toBeLessThan(watchOrder); }); + + // Maintenance window gate — maybeFastResyncAfterUpdate must respect the same + // window check as watchFromCron (Fix 2, #321). + test('callback does not call watchContainer when maintenance window is closed', async () => { + await docker.register('watcher', 'docker', 'test', { maintenancewindow: '0 2 * * *' }); + await docker.init(); + + const mockLog = createMockLog(['info', 'warn', 'debug', 'error']); + docker.log = mockLog; + + const matchedContainer = { name: 'myapp', watcher: 'test' }; + storeContainer.getContainers.mockReturnValue([matchedContainer]); + event.getContainerUpdateAppliedEventContainerName.mockReturnValue('test_myapp'); + maintenance.isInMaintenanceWindow.mockReturnValue(false); // window CLOSED + + docker.watchContainer = vi.fn().mockResolvedValue({}); + + const registeredCallback = event.registerContainerUpdateApplied.mock.calls[0][0]; + await registeredCallback('test_myapp'); + + expect(docker.watchContainer).not.toHaveBeenCalled(); + expect(mockLog.debug).toHaveBeenCalledWith( + expect.stringContaining('outside maintenance window'), + ); + }); + + test('callback calls watchContainer when maintenance window is open', async () => { + await docker.register('watcher', 'docker', 'test', { maintenancewindow: '0 2 * * *' }); + await docker.init(); + + const matchedContainer = { name: 'myapp', watcher: 'test' }; + storeContainer.getContainers.mockReturnValue([matchedContainer]); + event.getContainerUpdateAppliedEventContainerName.mockReturnValue('test_myapp'); + maintenance.isInMaintenanceWindow.mockReturnValue(true); // window OPEN + + docker.watchContainer = vi.fn().mockResolvedValue({}); + + const registeredCallback = event.registerContainerUpdateApplied.mock.calls[0][0]; + await registeredCallback('test_myapp'); + + expect(docker.watchContainer).toHaveBeenCalledWith(matchedContainer, { + emitBatchEvent: false, + }); + }); + + test('callback calls watchContainer when no maintenance window is configured', async () => { + await docker.register('watcher', 'docker', 'test', {}); // no maintenancewindow + await docker.init(); + + const matchedContainer = { name: 'myapp', watcher: 'test' }; + storeContainer.getContainers.mockReturnValue([matchedContainer]); + event.getContainerUpdateAppliedEventContainerName.mockReturnValue('test_myapp'); + + docker.watchContainer = vi.fn().mockResolvedValue({}); + + const registeredCallback = event.registerContainerUpdateApplied.mock.calls[0][0]; + await registeredCallback('test_myapp'); + + expect(docker.watchContainer).toHaveBeenCalledWith(matchedContainer, { + emitBatchEvent: false, + }); + }); }); describe('OIDC Remote Auth', () => { diff --git a/app/watchers/providers/docker/Docker.ts b/app/watchers/providers/docker/Docker.ts index f062e455..5003d5b3 100644 --- a/app/watchers/providers/docker/Docker.ts +++ b/app/watchers/providers/docker/Docker.ts @@ -912,6 +912,14 @@ class Docker extends Watcher { if (!matched) { return; } + // Gate the fast resync by the maintenance window — if the window is closed, skip + // the resync scan just as watchFromCron does. + if (this.configuration.maintenancewindow && !this.isMaintenanceWindowOpen()) { + this.log.debug( + `Skipping fast resync after update for "${matched.name}" — outside maintenance window`, + ); + return; + } // Mark the epoch BEFORE starting the resync scan so that any cron scan // already in-flight (watchStartedAtMs <= clearedAtMs) is suppressed by // the preserveClearedUpdateState guard in container-processing.ts and diff --git a/ui/src/types/container.d.ts b/ui/src/types/container.d.ts index 035dac00..4b9217e9 100644 --- a/ui/src/types/container.d.ts +++ b/ui/src/types/container.d.ts @@ -19,7 +19,8 @@ export type UpdateBlockerReason = | 'trigger-not-included' | 'agent-mismatch' | 'no-update-trigger-configured' - | 'self-update-unavailable'; + | 'self-update-unavailable' + | 'maintenance-window-closed'; /** * Severity controls how the UI gates the Update button: diff --git a/ui/src/utils/container-mapper.ts b/ui/src/utils/container-mapper.ts index e5c7c935..4975bb81 100644 --- a/ui/src/utils/container-mapper.ts +++ b/ui/src/utils/container-mapper.ts @@ -700,6 +700,7 @@ const VALID_UPDATE_BLOCKER_REASONS: ReadonlySet = new Set([ 'trigger-not-included', 'agent-mismatch', 'no-update-trigger-configured', + 'maintenance-window-closed', ]); function isUpdateBlockerReason(value: unknown): value is UpdateBlockerReason { diff --git a/ui/src/utils/update-eligibility.ts b/ui/src/utils/update-eligibility.ts index 5a23f911..a9031003 100644 --- a/ui/src/utils/update-eligibility.ts +++ b/ui/src/utils/update-eligibility.ts @@ -28,6 +28,8 @@ export const BLOCKER_SEVERITY: Record { 'trigger-not-included', 'agent-mismatch', 'no-update-trigger-configured', + 'maintenance-window-closed', ] as const; for (const reason of ALL_VALID_REASONS) { diff --git a/ui/tests/utils/update-eligibility.spec.ts b/ui/tests/utils/update-eligibility.spec.ts index 7646f563..747d1913 100644 --- a/ui/tests/utils/update-eligibility.spec.ts +++ b/ui/tests/utils/update-eligibility.spec.ts @@ -52,6 +52,7 @@ describe('BLOCKER_SEVERITY', () => { expect(BLOCKER_SEVERITY['threshold-not-reached']).toBe('soft'); expect(BLOCKER_SEVERITY['trigger-excluded']).toBe('soft'); expect(BLOCKER_SEVERITY['trigger-not-included']).toBe('soft'); + expect(BLOCKER_SEVERITY['maintenance-window-closed']).toBe('soft'); }); }); From be513f13d3a9b9fd93ad90bcf71469836aede797 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 15 Jun 2026 08:58:19 -0400 Subject: [PATCH 05/11] =?UTF-8?q?=F0=9F=A7=AA=20test(tag):=20fill=20covera?= =?UTF-8?q?ge=20gaps=20in=20tag-candidates.ts=20to=20reach=20100%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests for all previously-uncovered branches and functions: - specific-tag pin gate without a debug function - excludeTags regex compile failure (null safeRegExp) - over-length regex pattern (>1024 chars) - no-prefix candidate warning when tag starts with a digit - undefined/null tagFamily falling back to strict - invalid tagFamily value warning + fallback - logSemverCandidateFilterStats no-debug-function early return - filterSemverOnly callback (non-semver tag + includeTags with semver candidates) - isSemverFamilyMatch with null referenceShape - afterSemver > 0 + afterFamily == 0 family warning path Fixes: #321 --- .../providers/docker/tag-candidates.test.ts | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) diff --git a/app/watchers/providers/docker/tag-candidates.test.ts b/app/watchers/providers/docker/tag-candidates.test.ts index 720e7d06..e27cd809 100644 --- a/app/watchers/providers/docker/tag-candidates.test.ts +++ b/app/watchers/providers/docker/tag-candidates.test.ts @@ -710,4 +710,256 @@ describe('docker tag candidates module', () => { const avgMs = totalMs / runs; expect(avgMs).toBeLessThan(200); }); + + // Coverage for branch: excludeTags compile fails (safeRegExp returns null) → no filtering + test('skips exclude filtering when excludeTags regex fails to compile', () => { + const compileSpy = vi.spyOn(RE2JS, 'compile').mockImplementationOnce(() => { + throw new Error('bad regex'); + }); + const log = { warn: vi.fn(), debug: vi.fn() }; + + const result = getTagCandidates( + createContainer({ excludeTags: '[invalid' }), + ['1.0.1', '1.0.2'], + log, + ); + + // With failed excludeTags compile, all tags pass through + expect(result.tags.length).toBeGreaterThanOrEqual(0); + compileSpy.mockRestore(); + }); + + // Coverage for branch: specific-tag pin gate without debug function + test('specific pin gate does not throw when logContainer has no debug function', () => { + const container = createContainer({ + image: { + tag: { + value: 'v1.0.0', + semver: true, + tagPrecision: 'specific', + }, + }, + tagFamily: 'strict', + }); + const log = { warn: vi.fn() }; + + const result = getTagCandidates(container, ['v1.0.1', 'v1.1.0'], log as any); + + expect(result.tags).toEqual([]); + expect(result.noUpdateReason).toContain('Pinned tag'); + }); + + // Coverage for pre-existing branch: regex pattern length exceeds 1024 chars + test('warns and returns null when includeTags regex pattern exceeds max length', () => { + const log = { warn: vi.fn(), debug: vi.fn() }; + const overLongPattern = 'a'.repeat(1025); + + const result = getTagCandidates( + createContainer({ includeTags: overLongPattern }), + ['1.0.1'], + log, + ); + + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining('Regex pattern exceeds maximum length'), + ); + // When includeTags compile fails, baseTags is unfiltered — falls through non-semver path + expect(result).toBeDefined(); + }); + + // Coverage for pre-existing branch: no prefix (tag starts with a digit, no prefix string) + test('warns with no-prefix message when current tag has no alphabetic prefix', () => { + const container = createContainer({ + image: { + tag: { + value: '1.2.3', + semver: true, + }, + }, + }); + const log = { warn: vi.fn(), debug: vi.fn() }; + + // Pass tags that don't start with digits and don't start with '1' + getTagCandidates(container, ['v1.2.4', 'v1.3.0'], log); + + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining('No tags found starting with a number'), + ); + }); + + // Coverage for pre-existing branch: tagFamily is undefined/falsy → strict + test('returns strict when tagFamily is undefined', () => { + const container = createContainer({ + image: { + tag: { + value: '1.2.3', + semver: true, + }, + }, + tagFamily: undefined, + }); + const log = { warn: vi.fn(), debug: vi.fn() }; + + // Should not warn about invalid tagFamily when it's undefined + const result = getTagCandidates(container, ['1.2.4'], log); + expect(log.warn).not.toHaveBeenCalledWith(expect.stringContaining('Invalid tag family')); + expect(result.tags).toContain('1.2.4'); + }); + + // Coverage for pre-existing branch: isSemverFamilyMatch with null referenceShape + test('accepts any candidate when current tag has no parseable numeric shape', () => { + // Tag 'latest' has no numeric segments → referenceShape is null → isSemverFamilyMatch returns true + const container = createContainer({ + image: { + tag: { + value: 'latest', + semver: true, + // No tagPrecision set — not 'floating', so floating gate doesn't fire + }, + }, + includeTags: '^\\d+', + }); + const log = { warn: vi.fn(), debug: vi.fn() }; + + const result = getTagCandidates(container, ['1.0.0', '2.0.0'], log); + + // With no referenceShape, family matching is skipped — any semver candidate passes + expect(result.tags.length).toBeGreaterThan(0); + }); + + // Coverage for pre-existing branch: invalid tagFamily warns and falls back to strict + test('warns and falls back to strict when tagFamily has an invalid value', () => { + const container = createContainer({ + image: { + tag: { + value: '1.2.3', + semver: true, + }, + }, + tagFamily: 'invalid-value', + }); + const log = { warn: vi.fn(), debug: vi.fn() }; + + // Should still run and fall back to strict behavior + const result = getTagCandidates(container, ['1.2.4', '1.3.0'], log); + + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining('Invalid tag family policy "invalid-value"'), + ); + expect(result.tags).toContain('1.2.4'); + }); + + // Coverage for pre-existing branch: logSemverCandidateFilterStats returns early when no debug function + test('does not throw when logContainer has no debug function during stats logging', () => { + const container = createContainer({ + image: { + tag: { + value: '1.2.3', + semver: true, + }, + }, + }); + // Logger without debug function + const log = { warn: vi.fn() }; + + expect(() => getTagCandidates(container, ['1.2.4'], log as any)).not.toThrow(); + }); + + // Coverage for pre-existing branch: non-semver tag + includeTags returns semver tags from filtered set + test('returns semver tags for non-semver tag with includeTags when filtered tags include semver', () => { + const container = createContainer({ + image: { + tag: { + value: 'nightly', + semver: false, + }, + }, + includeTags: '^v', + }); + const log = { warn: vi.fn(), debug: vi.fn() }; + + // Include filter passes v-prefixed tags; filterSemverOnly keeps valid semver only + const result = getTagCandidates(container, ['v1.0.0', 'v1.1.0', 'not-semver'], log); + + expect(result.tags).toContain('v1.1.0'); + expect(result.tags).toContain('v1.0.0'); + }); + + // Coverage for branch: non-semver tag + includeTags when none of the filtered tags parse as semver + test('returns empty tags for non-semver tag with includeTags when no filtered tags are semver', () => { + const container = createContainer({ + image: { + tag: { + value: 'nightly', + semver: false, + }, + }, + includeTags: '^v\\d+\\.\\d+\\.\\d+$', + }); + const log = { warn: vi.fn(), debug: vi.fn() }; + + // All tags pass include filter but none parse as semver + const result = getTagCandidates(container, ['nightly', 'unstable'], log); + + expect(result.tags).toEqual([]); + }); + + // Coverage for pre-existing branch: warn when filteredTags is empty after include/exclude + test('warns when all tags are filtered out before semver candidate pass', () => { + const container = createContainer({ + image: { + tag: { + value: '1.0.0', + semver: true, + }, + }, + includeTags: '^will-never-match-anything$', + }); + const log = { warn: vi.fn(), debug: vi.fn() }; + + getTagCandidates(container, ['1.0.1', '1.0.2'], log); + + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('No tags found after filtering')); + }); + + // Coverage for pre-existing branch: warn when no tags pass prefix filter + test('warns when no candidate tags match the current tag prefix', () => { + const container = createContainer({ + image: { + tag: { + value: 'v1.0.0', + semver: true, + }, + }, + }); + const log = { warn: vi.fn(), debug: vi.fn() }; + + // All candidates start with 'r' not 'v' + getTagCandidates(container, ['r1.0.1', 'r1.0.2'], log); + + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining("No tags found with existing prefix: 'v'"), + ); + }); + + // Coverage for pre-existing branch: warn when semver candidates exist but none in same family + test('warns when semver tags exist but none are in the same inferred family', () => { + const container = createContainer({ + image: { + tag: { + value: '1.2.3-arm64', + semver: true, + }, + }, + tagFamily: 'strict', + }); + const log = { warn: vi.fn(), debug: vi.fn() }; + + // Candidates are valid semver but with different suffix family + getTagCandidates(container, ['1.2.4-amd64', '1.3.0-amd64'], log); + + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining('No tags found in the same inferred family'), + ); + }); }); From 3dacac44521612401577482a33cf66e2caaa44b2 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 15 Jun 2026 08:58:42 -0400 Subject: [PATCH 06/11] =?UTF-8?q?=F0=9F=93=9D=20docs(changelog):=20add=20F?= =?UTF-8?q?ix=201=20+=20Fix=202=20entries=20for=20#321?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - specific-tag pin: containers with 3-segment semver now digest-only by default - maintenance-window gate: now applies at update execution, not just detection --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7860959..b1cfc04d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ scheme restriction) live in `UPGRADE-NOTES.md` and are auto-appended to every ### Fixed +- **Containers pinned to a fully-specified semver tag (e.g. `image:v1.13.3`, 3+ numeric segments) no longer climb to newer versions by default ([#321](https://github.com/CodesWhat/drydock/issues/321)).** Tags classified as `specific` precision are now treated as digest-only by default — `getTagCandidates` returns an empty tag list so updates track digest changes only, not semver version bumps. Opt in to semver climbing by setting `dd.tag.include` (restricts climbing to matching tags) or `dd.tag.family=loose` (unrestricted climbing as before). Floating tags (`latest`, `16-alpine`, etc.) and 1–2-segment partial versions are unaffected. + +- **Maintenance window now gates when auto-updates are applied, not just when update checks run ([#321](https://github.com/CodesWhat/drydock/issues/321)).** Previously `watchFromCron` respected the window, but `maybeFastResyncAfterUpdate` (the post-update fast resync) called `watchContainer` unconditionally — allowing a triggered resync to detect a new image and dispatch an update outside the window. The fast resync now mirrors the same maintenance-window guard used by `watchFromCron`. Additionally, `computeUpdateEligibility` gains a `maintenanceWindowOpen` context field: when `false`, a soft `maintenance-window-closed` blocker is recorded in the eligibility result. Manual UI/API-triggered updates pass `undefined` and remain ungated. + - **Self-update overlay no longer flickers — the UI holds the "Applying Update" screen until the swap is actually complete.** Three compounding defects made the self-update experience look broken: (1) the UI's connectivity probe treated *any* successful `/auth/user` response as "update finished", but the old server keeps answering during the image pull — so the overlay dismissed almost immediately, then the page died again when the old container actually stopped; (2) the in-progress self-update operation could never be recorded as completed: the finalize callback secret was regenerated per-process (the helper's POST always failed with 403 after the restart) and startup reconciliation expired the operation before the helper could finalize it; and (3) the transient `drydock-self-update-` helper container carried no watch-exclusion label, so it flashed into the container list with watch-by-default enabled. The finalize secret is now per-operation, with its SHA-256 hash persisted on the operation row so the restarted process can validate the helper's callback; startup reconciliation grants fresh in-progress self-update operations a 10-minute grace window (with expiry as the bounded fallback if the helper dies); a new unauthenticated `GET /api/v1/self-update/{operationId}/status` endpoint reports the operation state while the session is unavailable mid-restart; the UI polls that endpoint during a self-update and only reloads once the operation reaches a terminal state (`succeeded`, `rolled-back`, `failed`, or `expired`); and the helper container is labeled `dd.watch=false` so it never appears in the watcher's container list. The UI also closes its SSE connection when self-update mode begins (after the ack is delivered) — the browser's built-in EventSource retry would otherwise reconnect to the new server, clear the overlay before the swap was committed, and leave the SPA running stale pre-update assets. Dry-run mode no longer shows the overlay at all: the UI notification is skipped and the no-op operation is marked terminal immediately instead of lingering in-progress. ## [1.5.0-rc.35] — 2026-06-10 From 8f9d4ea7120a8e97d509df47830f0d2c84c71112 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:07:44 -0400 Subject: [PATCH 07/11] =?UTF-8?q?=F0=9F=94=92=20security(docs):=20harden?= =?UTF-8?q?=20changelog=20comment=20stripping=20against=20CodeQL=20incompl?= =?UTF-8?q?ete-sanitization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single-pass //g regex in the changelog MDX generator with a complete indexOf-based scan. CodeQL flags the regex as js/incomplete-multi-character-sanitization (high) because a single replace can leave a residual " span and leaves any unterminated trailing "/g, "") - .replace(/\n{3,}/g, "\n\n"); +// +// An index scan is used instead of a `//g` replace on purpose: a +// single-pass regex replace can leave a residual "", open + 4); + if (close === -1) { + break; + } + result += text.slice(cursor, open); + cursor = close + 3; + } + return result + text.slice(cursor); +} + +const body = stripHtmlComments(changelogMd.replace(/^# Changelog\n/, "")).replace( + /\n{3,}/g, + "\n\n", +); // Write changelog into the current (v1.5) source dir so it gets copied const changelogDir = join(repoRoot, "content", "docs", "current", "changelog"); From 94be5d6a844eec8d84588b8e186564d1b0a8f956 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:28:56 -0400 Subject: [PATCH 08/11] =?UTF-8?q?=F0=9F=A7=AA=20test(tag):=20cover=20suffi?= =?UTF-8?q?xed/4-segment=20pinned=20tags=20+=20accurate=20maintenance-wind?= =?UTF-8?q?ow=20JSDoc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address adversarial-verifier findings on the rc.36 #321 fixes: - Add getTagCandidates gate tests for the most common real-world pinned patterns — suffixed 3-segment (1.13.3-bookworm) and 4-segment (1.2.3.4) — asserting digest-only (tags === []), no semver climb. - Rewrite the maintenanceWindowOpen JSDoc to be accurate: no auto-trigger dispatch path sets it; window enforcement is done at scan time in the Docker watcher. Prevents a future-maintainer trap. - Tighten a tautological assertion to an exact-array check. --- app/model/update-eligibility.ts | 9 ++-- .../providers/docker/tag-candidates.test.ts | 48 ++++++++++++++++++- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/app/model/update-eligibility.ts b/app/model/update-eligibility.ts index db9a3812..c30e3081 100644 --- a/app/model/update-eligibility.ts +++ b/app/model/update-eligibility.ts @@ -103,9 +103,12 @@ export interface UpdateEligibilityContext { now?: number; isSelfUpdateAvailable?: boolean; /** - * Set to `false` by auto-trigger dispatch to block updates outside the maintenance window. - * When `undefined` (the default for manual API/UI requests) the check is skipped so that - * operators can always force an update regardless of the window. + * Optional. When explicitly set to `false` by a caller, a soft `maintenance-window-closed` + * blocker is recorded in the eligibility result. Defaults to `undefined`. + * NOTE: No auto-trigger dispatch path currently sets this field — auto-update window + * enforcement is done at scan time in the Docker watcher (watchFromCron / + * maybeFastResyncAfterUpdate return early when the window is closed). Manual UI/API update + * requests leave it undefined and are never gated by it. */ maintenanceWindowOpen?: boolean; } diff --git a/app/watchers/providers/docker/tag-candidates.test.ts b/app/watchers/providers/docker/tag-candidates.test.ts index e27cd809..b6e0f8eb 100644 --- a/app/watchers/providers/docker/tag-candidates.test.ts +++ b/app/watchers/providers/docker/tag-candidates.test.ts @@ -671,6 +671,50 @@ describe('docker tag candidates module', () => { expect(result.tags).toEqual([]); expect(result.noUpdateReason).toBeUndefined(); }); + + test('specific suffixed 3-segment 1.13.3-bookworm, no labels → digest-only, no semver climb', () => { + const container = createContainer({ + image: { + tag: { + value: '1.13.3-bookworm', + semver: true, + tagPrecision: 'specific', + }, + }, + tagFamily: 'strict', + }); + const log = { warn: vi.fn(), debug: vi.fn() }; + + const result = getTagCandidates( + container, + ['1.13.3-bookworm', '1.13.4-bookworm', '1.14.0-bookworm', '1.46.1-bookworm'], + log, + ); + + expect(result.tags).toEqual([]); + expect(result.noUpdateReason).toContain('Pinned tag'); + expect(log.debug).toHaveBeenCalledWith(expect.stringContaining('Pinned tag')); + }); + + test('specific 4-segment 1.2.3.4, no labels → digest-only, no semver climb', () => { + const container = createContainer({ + image: { + tag: { + value: '1.2.3.4', + semver: true, + tagPrecision: 'specific', + }, + }, + tagFamily: 'strict', + }); + const log = { warn: vi.fn(), debug: vi.fn() }; + + const result = getTagCandidates(container, ['1.2.3.4', '1.2.3.5', '1.3.0.0'], log); + + expect(result.tags).toEqual([]); + expect(result.noUpdateReason).toContain('Pinned tag'); + expect(log.debug).toHaveBeenCalledWith(expect.stringContaining('Pinned tag')); + }); }); test('processes large tag lists within lightweight runtime budget', () => { @@ -724,8 +768,8 @@ describe('docker tag candidates module', () => { log, ); - // With failed excludeTags compile, all tags pass through - expect(result.tags.length).toBeGreaterThanOrEqual(0); + // With failed excludeTags compile, all tags pass through unfiltered (sorted descending) + expect(result.tags).toEqual(['1.0.2', '1.0.1']); compileSpy.mockRestore(); }); From 63e72feac2017e982faec15c6e189deef37715c4 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:32:15 -0400 Subject: [PATCH 09/11] =?UTF-8?q?=F0=9F=94=A7=20chore(release):=20v1.5.0-r?= =?UTF-8?q?c.36=20=E2=80=94=20CHANGELOG=20heading=20for=20release-cut=20va?= =?UTF-8?q?lidator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert the accumulated [Unreleased] section into the dated ## [1.5.0-rc.36] release section so scripts/extract-changelog-entry.mjs (invoked by release-cut.yml) finds the entry for tag v1.5.0-rc.36. This RC ships: - #321 Fix 1: fully-pinned specific-precision tags are digest-only by default (no surprise semver climb); opt in via dd.tag.include or dd.tag.family=loose. - #321 Fix 2: maintenance window gates the post-update fast resync so auto-updates are not applied outside the window. - Experimental Portwing edge-agent mode (PR #429/#430). - Self-update overlay flicker fix (#425). - CodeQL-clean changelog MDX generation (unblocks the website deploy). --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1cfc04d..d9f92014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ scheme restriction) live in `UPGRADE-NOTES.md` and are auto-appended to every 1.4.6+ / 1.5.x release's notes by `scripts/append-upgrade-notes.mjs` (wired into `release-cut.yml`). Update that file — not this comment — when the notes change. --> +## [1.5.0-rc.36] — 2026-06-15 + ### Added - **Experimental Portwing edge-agent mode — agents behind NAT or firewalls can now dial OUT to drydock over a persistent `wss://` WebSocket instead of waiting for an inbound controller connection ([PR #429](https://github.com/CodesWhat/drydock/pull/429), M5).** The feature is **experimental** and opt-in: set `DD_EXPERIMENTAL_PORTWING=true` to enable it. When disabled, the endpoint is not mounted and the feature has zero runtime footprint. Once enabled, agents connect to `WS /api/v1/portwing/ws` using the `portwing/1.0` subprotocol. Authentication is Ed25519 public-key challenge-response with timestamp + nonce replay protection (±60 s clock-skew window, 16 MB maximum frame size). Operator key management is exposed through a REST registry at `/api/v1/portwing/keys` (list, register, revoke). Because the feature is experimental the protocol and API surface may change in a future release without a deprecation notice. From 7d28eebb61985b4a05873343097db58746290158 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:56:55 -0400 Subject: [PATCH 10/11] =?UTF-8?q?=F0=9F=A7=AA=20test(e2e):=20opt=20Nginx?= =?UTF-8?q?=20(Hooked)=20fixture=20into=20family=20climbing=20post-#321=20?= =?UTF-8?q?gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #321 fix gates bare specific (3-segment) tags to digest-only updates by default. The Playwright 'actions tab' test opens Nginx (Hooked) and asserts an Update Now / Force Update control renders, which requires an available update. Without an include the container is correctly 'no-update-available' (hard blocker → disabled Blocked button), so the test counted zero controls. Add dd.tag.include=^1\.\d+\.\d+-alpine$ — the explicit family opt-in that survives the gate (identical to the edge-nginx fixture) — restoring the 'has an update' precondition main relied on via the buggy climb, now via a legitimate mechanism. Net update-state for all other specs is unchanged. Refs: #321 --- test/qa-compose.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/qa-compose.yml b/test/qa-compose.yml index cd190a0c..e0780cc6 100644 --- a/test/qa-compose.yml +++ b/test/qa-compose.yml @@ -191,6 +191,10 @@ services: - dd.display.name=Nginx (Hooked) - dd.display.icon=si:si:nginx - dd.group=web-stack + # Opt into climbing within the 1.x.x-alpine family. Required since the #321 + # fix gates bare specific (3-segment) tags to digest-only by default; without + # this include the hooks demo would never have an update to run hooks against. + - dd.tag.include=^1\.\d+\.\d+-alpine$$ - dd.hook.pre=echo "pre-update check" - dd.hook.post=echo "post-update cleanup" - dd.hook.timeout=30000 From 445c25635c566543f48a15606a8e0e8537255c96 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:15:12 -0400 Subject: [PATCH 11/11] =?UTF-8?q?=F0=9F=A7=AA=20test(e2e):=20give=20Redis?= =?UTF-8?q?=20Cache=20fixture=20a=20tag.include=20for=20the=20actions-tab?= =?UTF-8?q?=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The actions-tab Playwright test opens openAnyContainerDetail(), which is supposed to open Nginx (Hooked) but cannot: its matcher /\bNginx \(Hooked\)\b/ can never match a name ending in ')' (no word boundary after the paren), so the helper falls through to the next known container — Redis Cache (redis:7.2.0-alpine). Redis Cache had no include, so the #321 fix correctly gates it to digest-only → no-update-available hard blocker → disabled Blocked button, and the test counted zero Update Now/Force Update controls. Add dd.tag.include=^7\.\d+\.\d+-alpine$ so it climbs within its family and shows an update — exactly the state main relied on via the buggy climb, now via a legitimate mechanism. Verified the opened container and regex behavior against the failed run's error-context snapshot. Refs: #321 --- test/qa-compose.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/qa-compose.yml b/test/qa-compose.yml index e0780cc6..2b227c2b 100644 --- a/test/qa-compose.yml +++ b/test/qa-compose.yml @@ -210,6 +210,11 @@ services: - dd.display.name=Redis Cache - dd.display.icon=si:redis - dd.group=web-stack + # Opt into 7.x.x-alpine family climbing. This is the container the Playwright + # 'actions tab' test actually opens (openAnyContainerDetail skips Nginx (Hooked) + # because its \b…\) regex can't match a name ending in a paren), so it must have + # an available update post-#321 gate or the Update Now control never renders. + - dd.tag.include=^7\.\d+\.\d+-alpine$$ # Traefik with auto-rollback (tests rollback UI) # Icon uses dash separator (standard format — baseline comparison)