Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,20 @@ 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 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.

### 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-<timestamp>` 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
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ Auto-pull and recreate services via Docker Engine API with YAML-preserving servi
</td>
<td align="center">
<h3>Distributed Agents</h3>
Monitor remote Docker hosts with SSE-based agent architecture. <strong>Experimental:</strong> edge agents behind NAT/firewall dial out to drydock via WebSocket with Ed25519 public-key auth β€” no inbound port required (<code>DD_EXPERIMENTAL_LOOKOUT=true</code>)
Monitor remote Docker hosts with SSE-based agent architecture. <strong>Experimental:</strong> edge agents behind NAT/firewall dial out to drydock via WebSocket with Ed25519 public-key auth β€” no inbound port required (<code>DD_EXPERIMENTAL_PORTWING=true</code>)
</td>
<td align="center">
<h3>Audit Log</h3>
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -489,11 +489,11 @@ Thanks to the users who helped test v1.4.0 and v1.5.0 release candidates and rep
<table>
<tr><th>Tool</th><th>Role</th></tr>
<tr><td><b>drydock</b></td><td>Container update monitoring β€” web UI and notification engine</td></tr>
<tr><td><a href="https://github.com/CodesWhat/lookout"><b>lookout</b></a></td><td>Remote Docker agent β€” secure socket-level access from Drydock or standalone</td></tr>
<tr><td><a href="https://github.com/CodesWhat/portwing"><b>portwing</b></a></td><td>Remote Docker agent β€” secure socket-level access from Drydock or standalone</td></tr>
<tr><td><a href="https://github.com/CodesWhat/sockguard"><b>sockguard</b></a></td><td>Docker socket proxy β€” default-deny allowlist filter protecting the socket</td></tr>
</table>

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.

---

Expand Down
4 changes: 2 additions & 2 deletions app/agent/EdgeAgentAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ function createMockWs(): WebSocketLike & {
function createHello(overrides: Partial<HelloMessage> = {}): 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',
Expand All @@ -117,7 +117,7 @@ function createHello(overrides: Partial<HelloMessage> = {}): HelloMessage {
function createAdapter(hello?: Partial<HelloMessage>) {
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: '',
Expand Down
10 changes: 5 additions & 5 deletions app/agent/EdgeAgentAdapter.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -63,7 +63,7 @@ interface AgentComponentDescriptor {
configuration: Record<string, unknown>;
}

interface LookoutFrame {
interface PortwingFrame {
type: string;
data: Record<string, unknown>;
}
Expand Down Expand Up @@ -157,9 +157,9 @@ export class EdgeAgentAdapter {
}

private async onMessage(raw: unknown): Promise<void> {
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;
Expand Down
24 changes: 12 additions & 12 deletions app/api/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('../configuration/index.js')>();
return {
...actual,
getExperimentalLookoutEnabled: mockGetExperimentalLookoutEnabled,
getExperimentalPortwingEnabled: mockGetExperimentalPortwingEnabled,
};
});
vi.mock('./lookout', mockInit);
vi.mock('./portwing', mockInit);

describe('API Router', () => {
let api;
Expand All @@ -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();
Expand Down Expand Up @@ -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();
});
});
10 changes: 5 additions & 5 deletions app/api/api.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading