@aifabrix/builder 2.47.3 → 2.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/jest.isolated-projects.js +4 -0
  2. package/jest.projects.js +4 -0
  3. package/lib/api/types/dev.types.js +1 -1
  4. package/lib/app/down.js +3 -20
  5. package/lib/app/register.js +20 -18
  6. package/lib/app/rotate-secret.js +39 -16
  7. package/lib/build/index.js +12 -49
  8. package/lib/build/integration-docker-staging.js +35 -0
  9. package/lib/build/standard-docker-build.js +113 -0
  10. package/lib/cli/setup-app.js +4 -0
  11. package/lib/commands/dev-init.js +18 -0
  12. package/lib/commands/setup-prompts-platform-mode.js +11 -1
  13. package/lib/commands/setup.js +1 -0
  14. package/lib/commands/up-dataplane-credentials.js +88 -5
  15. package/lib/core/setup-platform-mode.js +16 -9
  16. package/lib/parameters/infra-kv-discovery.js +4 -3
  17. package/lib/utils/aifabrix-runtime-config-dir.js +2 -1
  18. package/lib/utils/compose-generate-docker-compose.js +2 -1
  19. package/lib/utils/config-paths.js +2 -2
  20. package/lib/utils/dev-ca-docker-augment.js +54 -0
  21. package/lib/utils/docker-daemon-tls-ca.js +326 -0
  22. package/lib/utils/ensure-dev-certs-for-remote-docker.js +5 -0
  23. package/lib/utils/promote-dev-docker-image.js +170 -0
  24. package/lib/utils/remote-docker-env.js +22 -7
  25. package/lib/utils/secrets-helpers.js +9 -14
  26. package/lib/utils/secrets-kv-refs.js +47 -3
  27. package/lib/utils/secrets-missing-error.js +1 -9
  28. package/lib/utils/token-manager-client-credentials.js +11 -1
  29. package/lib/utils/worker-sidecars.js +28 -1
  30. package/package.json +1 -1
  31. package/.temp/jest-protection-convert/run-IFN2nv/.protection/hubspot-companies.json +0 -1
  32. package/.temp/jest-protection-convert/run-IvmKLO/.protection/hubspot-companies.yaml +0 -17
  33. package/.temp/jest-protection-convert/run-SdvblF/.protection/hubspot-companies.json +0 -1
  34. package/.temp/jest-protection-convert/run-b51TsW/.protection/hubspot-companies.json +0 -1
  35. package/.temp/jest-protection-convert/run-fiLmb1/.protection/hubspot-companies.json +0 -1
  36. package/.temp/jest-protection-convert/run-qYGHMU/.protection/hubspot-companies.yaml +0 -17
  37. package/.temp/jest-protection-fixtures/hubspot-Mq5re8/hubspot-companies.yaml +0 -17
  38. package/.temp/jest-protection-fixtures/hubspot-f3u4YL/hubspot-companies.yaml +0 -17
  39. package/.temp/jest-protection-fixtures/hubspot-iQtd1O/hubspot-companies.yaml +0 -17
  40. package/.temp/jest-protection-fixtures/hubspot-mdmz5M/hubspot-companies.yaml +0 -17
  41. package/.temp/jest-protection-fixtures/hubspot-qbKYxQ/hubspot-companies.yaml +0 -17
  42. package/.temp/jest-protection-fixtures/hubspot-yzlJy6/hubspot-companies.yaml +0 -17
  43. package/.temp/validation/summary.txt +0 -5
@@ -16,6 +16,10 @@ const { loadClientCredentials } = require('../utils/token-manager');
16
16
  const { rotateSecret } = require('../app/rotate-secret');
17
17
  const logger = require('../utils/logger');
18
18
  const { formatSuccessLine } = require('../utils/cli-test-layout-chalk');
19
+ const {
20
+ resolveLocalhostPlatformControllerUrl,
21
+ resolveCliPlatformControllerUrl
22
+ } = require('../utils/platform-controller-url');
19
23
 
20
24
  const DATAPLANE_APP_KEY = 'dataplane';
21
25
  const MISALIGN_ERROR =
@@ -30,6 +34,31 @@ function normalizeControllerUrl(controllerUrl) {
30
34
  return String(controllerUrl || '').replace(/\/+$/, '');
31
35
  }
32
36
 
37
+ /**
38
+ * Controller bases to try for token probe (public Traefik, CLI-resolved, localhost).
39
+ * @async
40
+ * @param {string} controllerUrl
41
+ * @returns {Promise<string[]>}
42
+ */
43
+ async function resolveControllerUrlsForCredentialProbe(controllerUrl) {
44
+ const urls = [];
45
+ const add = (url) => {
46
+ const normalized = normalizeControllerUrl(url);
47
+ if (normalized && !urls.includes(normalized)) {
48
+ urls.push(normalized);
49
+ }
50
+ };
51
+
52
+ add(controllerUrl);
53
+ try {
54
+ add(await resolveCliPlatformControllerUrl());
55
+ add(await resolveLocalhostPlatformControllerUrl());
56
+ } catch {
57
+ // optional fallbacks only
58
+ }
59
+ return urls;
60
+ }
61
+
33
62
  /**
34
63
  * Exchange dataplane client id/secret for a controller token (probe only).
35
64
  * @async
@@ -52,17 +81,68 @@ async function probeDataplaneClientToken(controllerUrl, credentials) {
52
81
  return response.success === true;
53
82
  }
54
83
 
84
+ /**
85
+ * Load dataplane pipeline credentials from secrets.local.yaml only (ignore process env).
86
+ * @async
87
+ * @returns {Promise<{ clientId: string, clientSecret: string }|null>}
88
+ */
89
+ async function loadDataplanePipelineCredentialsFromSecretsFile() {
90
+ return loadClientCredentials(DATAPLANE_APP_KEY, { secretsFileOnly: true });
91
+ }
92
+
93
+ /**
94
+ * @param {number} ms
95
+ * @returns {Promise<void>}
96
+ */
97
+ function sleep(ms) {
98
+ return new Promise((resolve) => setTimeout(resolve, ms));
99
+ }
100
+
101
+ /**
102
+ * Probe token endpoint with retries (controller Redis may briefly serve stale pipeline config).
103
+ * @async
104
+ * @param {string} controllerUrl
105
+ * @param {number} [maxAttempts]
106
+ * @param {number} [delayMs]
107
+ * @returns {Promise<boolean>}
108
+ */
109
+ async function credentialsAcceptTokenWithRetry(controllerUrl, maxAttempts = 5, delayMs = 2000) {
110
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
111
+ if (await credentialsAcceptToken(controllerUrl)) {
112
+ return true;
113
+ }
114
+ if (attempt < maxAttempts - 1 && delayMs > 0) {
115
+ await sleep(delayMs);
116
+ }
117
+ }
118
+ return false;
119
+ }
120
+
121
+ function resolveCredentialProbeRetry(options = {}) {
122
+ const retry = options.credentialProbeRetry || {};
123
+ return {
124
+ maxAttempts: Number.isFinite(retry.maxAttempts) ? retry.maxAttempts : 5,
125
+ delayMs: Number.isFinite(retry.delayMs) ? retry.delayMs : 2000
126
+ };
127
+ }
128
+
55
129
  /**
56
130
  * @async
57
131
  * @param {string} controllerUrl
58
132
  * @returns {Promise<boolean>}
59
133
  */
60
134
  async function credentialsAcceptToken(controllerUrl) {
61
- const credentials = await loadClientCredentials(DATAPLANE_APP_KEY);
135
+ const credentials = await loadDataplanePipelineCredentialsFromSecretsFile();
62
136
  if (!credentials) {
63
137
  return false;
64
138
  }
65
- return probeDataplaneClientToken(controllerUrl, credentials);
139
+ const urls = await resolveControllerUrlsForCredentialProbe(controllerUrl);
140
+ for (const url of urls) {
141
+ if (await probeDataplaneClientToken(url, credentials)) {
142
+ return true;
143
+ }
144
+ }
145
+ return false;
66
146
  }
67
147
 
68
148
  /**
@@ -86,9 +166,10 @@ async function alignDataplaneCredentialsAfterDeploy(options, controllerUrl) {
86
166
  if (!quiet) {
87
167
  logger.log(chalk.blue('Aligning dataplane pipeline credentials with controller...'));
88
168
  }
89
- await rotateSecret(DATAPLANE_APP_KEY, { ...options, quiet });
169
+ await rotateSecret(DATAPLANE_APP_KEY, { ...options, quiet, exitOnFailure: false });
90
170
 
91
- const stillMisaligned = !(await credentialsAcceptToken(controllerUrl));
171
+ const { maxAttempts, delayMs } = resolveCredentialProbeRetry(options);
172
+ const stillMisaligned = !(await credentialsAcceptTokenWithRetry(controllerUrl, maxAttempts, delayMs));
92
173
  if (stillMisaligned) {
93
174
  throw new Error(MISALIGN_ERROR);
94
175
  }
@@ -100,5 +181,7 @@ async function alignDataplaneCredentialsAfterDeploy(options, controllerUrl) {
100
181
  module.exports = {
101
182
  alignDataplaneCredentialsAfterDeploy,
102
183
  probeDataplaneClientToken,
103
- credentialsAcceptToken
184
+ credentialsAcceptToken,
185
+ loadDataplanePipelineCredentialsFromSecretsFile,
186
+ resolveControllerUrlsForCredentialProbe
104
187
  };
@@ -48,7 +48,7 @@ function normalizeSetupPlatformMode(value) {
48
48
  * - full: Traefik ingress + env-scoped resources (multi-env; CLI only until menu returns)
49
49
  *
50
50
  * @param {'single'|'single-traefik'|'full'} mode
51
- * @returns {{ traefik: boolean, useEnvironmentScopedResources: boolean }}
51
+ * @returns {{ traefik: boolean, tlsEnabled: boolean, useEnvironmentScopedResources: boolean }}
52
52
  */
53
53
  function flagsForSetupPlatformMode(mode) {
54
54
  const m = normalizeSetupPlatformMode(mode);
@@ -56,28 +56,35 @@ function flagsForSetupPlatformMode(mode) {
56
56
  m === SETUP_PLATFORM_MODE.SINGLE_TRAEFIK || m === SETUP_PLATFORM_MODE.FULL;
57
57
  return {
58
58
  traefik,
59
+ tlsEnabled: traefik,
59
60
  useEnvironmentScopedResources: m === SETUP_PLATFORM_MODE.FULL
60
61
  };
61
62
  }
62
63
 
63
64
  /**
64
65
  * Interactive setup menu choices (full platform hidden until multi-env path ships).
66
+ * Traefik front door requires `remote-server` (from `aifabrix dev init`); without it only direct ports is offered.
65
67
  *
68
+ * @param {Object} [opts]
69
+ * @param {boolean} [opts.remoteServerConfigured=false]
66
70
  * @returns {{ name: string, value: string }[]}
67
71
  */
68
- function getSetupPlatformModePromptChoices() {
69
- return [
72
+ function getSetupPlatformModePromptChoices(opts = {}) {
73
+ const choices = [
70
74
  {
71
75
  name: 'Single environment — direct ports (no Traefik front door)',
72
76
  value: SETUP_PLATFORM_MODE.SINGLE
73
- },
74
- {
75
- name: 'Single environment — Traefik front door (domain + TLS)',
76
- value: SETUP_PLATFORM_MODE.SINGLE_TRAEFIK
77
77
  }
78
- // Full platform — Traefik front door + miso/dev/tst/pro (shared infra) — menu returns soon.
79
- // { name: 'Full platform — Traefik front door + miso/dev/tst/pro (shared infra)', value: SETUP_PLATFORM_MODE.FULL },
80
78
  ];
79
+ if (opts.remoteServerConfigured === true) {
80
+ choices.push({
81
+ name: 'Single environment — Traefik front door (domain + TLS)',
82
+ value: SETUP_PLATFORM_MODE.SINGLE_TRAEFIK
83
+ });
84
+ }
85
+ // Full platform — Traefik front door + miso/dev/tst/pro (shared infra) — menu returns soon.
86
+ // { name: 'Full platform — Traefik front door + miso/dev/tst/pro (shared infra)', value: SETUP_PLATFORM_MODE.FULL },
87
+ return choices;
81
88
  }
82
89
 
83
90
  module.exports = {
@@ -6,6 +6,7 @@
6
6
 
7
7
  const fsRealSync = require('../internal/fs-real-sync');
8
8
  const path = require('path');
9
+ const { activeEnvLineForKvScan } = require('../utils/secrets-kv-refs');
9
10
  const { loadRequiresDatabasesArray, loadShippedRequiresDatabases } = require('./database-secret-values');
10
11
 
11
12
  /** Platform apps from shipped templates (setup/up-infra before builder/ is copied). */
@@ -22,11 +23,11 @@ function extractKvKeysFromEnvContent(content) {
22
23
  const kvPattern = /kv:\/\/([a-zA-Z0-9-_]+)/g;
23
24
  const lines = content.split('\n');
24
25
  for (const line of lines) {
25
- const t = line.trim();
26
- if (t === '' || t.startsWith('#')) continue;
26
+ const scanLine = activeEnvLineForKvScan(line);
27
+ if (!scanLine) continue;
27
28
  let m;
28
29
  kvPattern.lastIndex = 0;
29
- while ((m = kvPattern.exec(line)) !== null) {
30
+ while ((m = kvPattern.exec(scanLine)) !== null) {
30
31
  keys.add(m[1]);
31
32
  }
32
33
  }
@@ -88,7 +88,8 @@ function resolveAifabrixHomeLikePath(raw) {
88
88
  return path.normalize(expanded);
89
89
  }
90
90
  if (isExplicitRelativeToCwd(expanded)) {
91
- return path.resolve(expanded);
91
+ // Use process.cwd() explicitly — Jest wraps cwd separately from path.resolve('.') (native getcwd).
92
+ return path.resolve(process.cwd(), expanded);
92
93
  }
93
94
  return path.resolve(safeHomedir(), expanded);
94
95
  }
@@ -153,7 +153,8 @@ function buildComposeTemplateContext(ctx) {
153
153
  useInfraPgpass: !!ctx.useInfraPgpass,
154
154
  workerSidecars: buildCeleryWorkerComposeContext(ctx.appName, ctx.appConfig, {
155
155
  devId: ctx.devId,
156
- idNum: ctx.idNum
156
+ idNum: ctx.idNum,
157
+ envFilePath: ctx.envFileAbsolutePath
157
158
  })
158
159
  };
159
160
  }
@@ -264,8 +264,8 @@ function createRemoteConfigGetters(getConfigFn) {
264
264
  return getPathConfig(getConfigFn, 'docker-endpoint');
265
265
  },
266
266
  /**
267
- * When true, Docker CLI may use DOCKER_TLS_VERIFY=0 only when ca.pem is absent (no trust anchor).
268
- * If ca.pem exists (e.g. after issue-cert), the daemon is always verified regardless of this flag.
267
+ * When true, Docker CLI uses DOCKER_TLS_VERIFY=0 (client certs still sent when present).
268
+ * Applies even when ca.pem exists (internal issue-cert CA vs public Docker daemon cert).
269
269
  */
270
270
  async getDockerTlsSkipVerify() {
271
271
  const envRaw = process.env.AIFABRIX_DOCKER_TLS_SKIP_VERIFY;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Persist ca.pem after merging Docker daemon TLS trust (wildcard/public CA).
3
+ *
4
+ * @fileoverview Write augmented ca.pem for remote Docker
5
+ * @author AI Fabrix Team
6
+ * @version 1.0.0
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const fs = require('fs').promises;
12
+ const path = require('path');
13
+ const { normalizePemNewlines, getCertDir } = require('./dev-cert-helper');
14
+ const {
15
+ resolveDockerEndpointForCaAugment,
16
+ augmentCaPemWithDockerDaemonChain,
17
+ writeDockerDaemonCaMarker
18
+ } = require('./docker-daemon-tls-ca');
19
+
20
+ /**
21
+ * @param {string} configDir
22
+ * @param {string} devId
23
+ * @param {string|null|undefined} existingCaPem
24
+ * @param {{ settings?: Object|null, remoteServerUrl?: string|null, warn?: (msg: string) => void }} ctx
25
+ * @returns {Promise<string|null>}
26
+ */
27
+ async function augmentAndSaveCaPemForRemoteDocker(configDir, devId, existingCaPem, ctx) {
28
+ const dockerEndpoint = resolveDockerEndpointForCaAugment({
29
+ settings: ctx && ctx.settings,
30
+ remoteServerUrl: ctx && ctx.remoteServerUrl
31
+ });
32
+ if (!dockerEndpoint) {
33
+ return existingCaPem || null;
34
+ }
35
+
36
+ const augmented = await augmentCaPemWithDockerDaemonChain(existingCaPem, dockerEndpoint, {
37
+ warn: ctx && ctx.warn
38
+ });
39
+ if (!augmented || augmented === existingCaPem) {
40
+ return existingCaPem || null;
41
+ }
42
+
43
+ const certDir = getCertDir(configDir, devId);
44
+ await fs.mkdir(certDir, { recursive: true });
45
+ await fs.writeFile(
46
+ path.join(certDir, 'ca.pem'),
47
+ normalizePemNewlines(augmented),
48
+ { mode: 0o600 }
49
+ );
50
+ await writeDockerDaemonCaMarker(certDir);
51
+ return augmented;
52
+ }
53
+
54
+ module.exports = { augmentAndSaveCaPemForRemoteDocker };
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Fetch Docker Engine TLS trust anchors when the daemon uses a public/wildcard server
3
+ * certificate (e.g. Sectigo *.aifabrix.dev) while issue-cert ca.pem holds the internal CA.
4
+ * Docker CLI verifies the daemon with ca.pem only (not the OS trust store).
5
+ *
6
+ * @fileoverview Docker daemon TLS CA chain for remote docker-endpoint
7
+ * @author AI Fabrix Team
8
+ * @version 1.0.0
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const tls = require('tls');
14
+ const { mergeCaPemBlocks } = require('./dev-cert-helper');
15
+
16
+ const DEFAULT_DOCKER_TLS_PORT = 2376;
17
+ const DEFAULT_FETCH_TIMEOUT_MS = 10000;
18
+ /** Written under ~/.aifabrix/certs/<developer-id>/ when ca.pem includes Docker daemon trust. */
19
+ const DOCKER_DAEMON_CA_MARKER = '.docker-daemon-ca-augmented';
20
+
21
+ /**
22
+ * @param {Buffer|string} der
23
+ * @returns {string}
24
+ */
25
+ function derCertToPem(der) {
26
+ const buf = Buffer.isBuffer(der) ? der : Buffer.from(der);
27
+ const lines = buf.toString('base64').match(/.{1,64}/g) || [];
28
+ return `-----BEGIN CERTIFICATE-----\n${lines.join('\n')}\n-----END CERTIFICATE-----`;
29
+ }
30
+
31
+ /**
32
+ * @param {import('tls').PeerCertificate} peerCert
33
+ * @returns {string[]}
34
+ */
35
+ function collectIssuerChainPems(peerCert) {
36
+ const pems = [];
37
+ const seen = new Set();
38
+ let cert = peerCert && peerCert.issuerCertificate;
39
+ while (cert && cert.raw) {
40
+ const fp = cert.fingerprint256 || cert.fingerprint;
41
+ if (fp) {
42
+ if (seen.has(fp)) break;
43
+ seen.add(fp);
44
+ }
45
+ if (peerCert.fingerprint && cert.fingerprint === peerCert.fingerprint) break;
46
+ pems.push(derCertToPem(cert.raw));
47
+ const next = cert.issuerCertificate;
48
+ if (!next || next === cert) break;
49
+ cert = next;
50
+ }
51
+ return pems;
52
+ }
53
+
54
+ /**
55
+ * Parse docker-endpoint (tcp://host:port) into TLS connect parameters.
56
+ * @param {string} endpoint
57
+ * @returns {{ host: string, port: number, servername: string }|null}
58
+ */
59
+ function parseDockerTlsEndpoint(endpoint) {
60
+ const trimmed = String(endpoint || '').trim();
61
+ if (!trimmed) return null;
62
+ const lower = trimmed.toLowerCase();
63
+ if (lower.startsWith('unix:')) return null;
64
+
65
+ let host = '';
66
+ let port = DEFAULT_DOCKER_TLS_PORT;
67
+
68
+ if (lower.startsWith('tcp://')) {
69
+ const rest = trimmed.slice(6).split('/')[0];
70
+ if (rest.startsWith('[')) {
71
+ const end = rest.indexOf(']');
72
+ if (end === -1) return null;
73
+ host = rest.slice(1, end);
74
+ const after = rest.slice(end + 1);
75
+ if (after.startsWith(':')) {
76
+ const p = parseInt(after.slice(1), 10);
77
+ if (!Number.isNaN(p)) port = p;
78
+ }
79
+ } else {
80
+ const colonIdx = rest.lastIndexOf(':');
81
+ if (colonIdx > 0 && /^\d+$/.test(rest.slice(colonIdx + 1))) {
82
+ port = parseInt(rest.slice(colonIdx + 1), 10);
83
+ host = rest.slice(0, colonIdx);
84
+ } else {
85
+ host = rest;
86
+ }
87
+ }
88
+ } else {
89
+ try {
90
+ const u = new URL(trimmed.includes('://') ? trimmed : `tcp://${trimmed}`);
91
+ host = u.hostname;
92
+ if (u.port) {
93
+ const p = parseInt(u.port, 10);
94
+ if (!Number.isNaN(p)) port = p;
95
+ }
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ host = String(host || '').trim();
102
+ if (!host) return null;
103
+ return { host, port, servername: host };
104
+ }
105
+
106
+ /**
107
+ * @param {string} remoteServerUrl
108
+ * @returns {string|null}
109
+ */
110
+ function dockerEndpointFromRemoteServer(remoteServerUrl) {
111
+ try {
112
+ const u = new URL(String(remoteServerUrl || '').trim().replace(/\/+$/, ''));
113
+ if (!u.hostname) return null;
114
+ return `tcp://${u.hostname}:${DEFAULT_DOCKER_TLS_PORT}`;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Resolve docker-endpoint for CA augmentation (settings override, else derive from remote-server).
122
+ * @param {{ settings?: Object|null, remoteServerUrl?: string|null }} ctx
123
+ * @returns {string|null}
124
+ */
125
+ function resolveDockerEndpointForCaAugment(ctx) {
126
+ const settings = ctx && ctx.settings;
127
+ const fromSettings = settings && settings['docker-endpoint'];
128
+ if (typeof fromSettings === 'string' && fromSettings.trim()) {
129
+ return fromSettings.trim();
130
+ }
131
+ return dockerEndpointFromRemoteServer(ctx && ctx.remoteServerUrl);
132
+ }
133
+
134
+ /**
135
+ * Connect to Docker Engine TLS port and return issuer-chain PEM for daemon server verification.
136
+ * @param {string} dockerEndpoint - e.g. tcp://builder01.aifabrix.dev:2376
137
+ * @param {{ timeoutMs?: number }} [opts]
138
+ * @returns {Promise<string|null>}
139
+ */
140
+ function fetchDockerDaemonTlsCaPem(dockerEndpoint, opts) {
141
+ const parsed = parseDockerTlsEndpoint(dockerEndpoint);
142
+ if (!parsed) {
143
+ return Promise.resolve(null);
144
+ }
145
+ const timeoutMs =
146
+ opts && typeof opts.timeoutMs === 'number' && opts.timeoutMs > 0
147
+ ? opts.timeoutMs
148
+ : DEFAULT_FETCH_TIMEOUT_MS;
149
+
150
+ return new Promise((resolve, reject) => {
151
+ let settled = false;
152
+
153
+ const socket = tls.connect(
154
+ {
155
+ host: parsed.host,
156
+ port: parsed.port,
157
+ servername: parsed.servername,
158
+ rejectUnauthorized: false
159
+ },
160
+ () => {
161
+ try {
162
+ const peer = socket.getPeerCertificate(true);
163
+ const chain = collectIssuerChainPems(peer);
164
+ socket.end();
165
+ finish(resolve, chain.length ? chain.join('\n\n') : null);
166
+ } catch (err) {
167
+ socket.destroy();
168
+ finish(reject, err);
169
+ }
170
+ }
171
+ );
172
+
173
+ const timer = setTimeout(() => {
174
+ socket.destroy(new Error(`Timed out fetching Docker daemon TLS chain (${timeoutMs}ms)`));
175
+ }, timeoutMs);
176
+
177
+ function finish(fn, value) {
178
+ if (settled) return;
179
+ settled = true;
180
+ clearTimeout(timer);
181
+ fn(value);
182
+ }
183
+
184
+ socket.on('error', (err) => {
185
+ finish(reject, err);
186
+ });
187
+ });
188
+ }
189
+
190
+ /**
191
+ * Merge Docker daemon public/wildcard trust anchors into ca.pem blocks.
192
+ * @param {string|null|undefined} existingCaPem
193
+ * @param {string|null|undefined} dockerEndpoint
194
+ * @param {{ timeoutMs?: number, warn?: (msg: string) => void }} [opts]
195
+ * @returns {Promise<string|null>}
196
+ */
197
+ async function augmentCaPemWithDockerDaemonChain(existingCaPem, dockerEndpoint, opts) {
198
+ if (!dockerEndpoint || typeof dockerEndpoint !== 'string' || !dockerEndpoint.trim()) {
199
+ return existingCaPem || null;
200
+ }
201
+ try {
202
+ const dockerCaPem = await fetchDockerDaemonTlsCaPem(dockerEndpoint.trim(), opts);
203
+ if (!dockerCaPem) {
204
+ return existingCaPem || null;
205
+ }
206
+ return mergeCaPemBlocks(existingCaPem, dockerCaPem);
207
+ } catch (err) {
208
+ const warn = opts && typeof opts.warn === 'function' ? opts.warn : null;
209
+ const msg = err && err.message ? err.message : String(err);
210
+ if (warn) {
211
+ warn(
212
+ `Could not fetch Docker daemon TLS chain from ${dockerEndpoint.trim()} (${msg}). ` +
213
+ 'Remote docker may fail until ca.pem includes the daemon trust anchor (re-run dev init or dev refresh).'
214
+ );
215
+ }
216
+ return existingCaPem || null;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Split PEM file into individual certificate blocks.
222
+ * @param {string} pem
223
+ * @returns {string[]}
224
+ */
225
+ function splitPemCertificateBlocks(pem) {
226
+ if (!pem || typeof pem !== 'string') return [];
227
+ return pem
228
+ .replace(/\r\n/g, '\n')
229
+ .split(/(?=-----BEGIN CERTIFICATE-----)/)
230
+ .map((block) => block.trim())
231
+ .filter(Boolean);
232
+ }
233
+
234
+ /**
235
+ * True when existing ca.pem already contains every issuer from the live Docker daemon chain.
236
+ * @param {string|null|undefined} existingCaPem
237
+ * @param {string|null|undefined} dockerEndpoint
238
+ * @param {{ timeoutMs?: number }} [opts]
239
+ * @returns {Promise<boolean>}
240
+ */
241
+ async function caPemTrustsDockerDaemon(existingCaPem, dockerEndpoint, opts) {
242
+ if (!existingCaPem || !dockerEndpoint) return false;
243
+ try {
244
+ const dockerChain = await fetchDockerDaemonTlsCaPem(dockerEndpoint.trim(), opts);
245
+ if (!dockerChain) return false;
246
+ const normalizedExisting = String(existingCaPem).replace(/\r\n/g, '\n');
247
+ const blocks = splitPemCertificateBlocks(dockerChain);
248
+ if (!blocks.length) return false;
249
+ return blocks.every((block) => normalizedExisting.includes(block));
250
+ } catch {
251
+ return false;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * @param {string} certDir
257
+ * @returns {Promise<void>}
258
+ */
259
+ async function writeDockerDaemonCaMarker(certDir) {
260
+ const fs = require('fs').promises;
261
+ const markerPath = require('path').join(certDir, DOCKER_DAEMON_CA_MARKER);
262
+ await fs.writeFile(markerPath, `augmentedAt=${new Date().toISOString()}\n`, { mode: 0o600 });
263
+ }
264
+
265
+ /**
266
+ * Decide whether Docker CLI should verify the daemon certificate (DOCKER_TLS_VERIFY=1).
267
+ * When ca.pem holds only the internal issue-cert CA but the daemon uses a public/wildcard cert,
268
+ * returns false so client mTLS still works with DOCKER_TLS_VERIFY=0.
269
+ *
270
+ * @param {{ certDir: string, existingCaPem: string, dockerEndpoint: string|null, remoteServerUrl?: string|null, settings?: Object|null, skipVerify?: boolean }} ctx
271
+ * @returns {Promise<boolean>}
272
+ */
273
+ async function resolveRemoteDockerDaemonVerify(ctx) {
274
+ if (ctx && ctx.skipVerify) return false;
275
+ const certDir = ctx && ctx.certDir;
276
+ const existingCaPem = ctx && ctx.existingCaPem;
277
+ if (!certDir || !existingCaPem) return false;
278
+
279
+ const fs = require('fs');
280
+ const path = require('path');
281
+ const markerPath = path.join(certDir, DOCKER_DAEMON_CA_MARKER);
282
+ if (fs.existsSync(markerPath)) return true;
283
+
284
+ const dockerEndpoint = resolveDockerEndpointForCaAugment({
285
+ settings: ctx.settings,
286
+ remoteServerUrl: ctx.remoteServerUrl
287
+ }) || (ctx.dockerEndpoint && String(ctx.dockerEndpoint).trim()) || null;
288
+ if (!dockerEndpoint) return true;
289
+
290
+ const fetchOpts = { timeoutMs: DEFAULT_FETCH_TIMEOUT_MS };
291
+ if (await caPemTrustsDockerDaemon(existingCaPem, dockerEndpoint, fetchOpts)) {
292
+ await writeDockerDaemonCaMarker(certDir);
293
+ return true;
294
+ }
295
+
296
+ const augmented = await augmentCaPemWithDockerDaemonChain(existingCaPem, dockerEndpoint, fetchOpts);
297
+ if (augmented && augmented !== existingCaPem) {
298
+ const { normalizePemNewlines } = require('./dev-cert-helper');
299
+ await fs.promises.writeFile(
300
+ path.join(certDir, 'ca.pem'),
301
+ normalizePemNewlines(augmented),
302
+ { mode: 0o600 }
303
+ );
304
+ await writeDockerDaemonCaMarker(certDir);
305
+ return true;
306
+ }
307
+
308
+ return false;
309
+ }
310
+
311
+ module.exports = {
312
+ DEFAULT_DOCKER_TLS_PORT,
313
+ DEFAULT_FETCH_TIMEOUT_MS,
314
+ DOCKER_DAEMON_CA_MARKER,
315
+ parseDockerTlsEndpoint,
316
+ dockerEndpointFromRemoteServer,
317
+ resolveDockerEndpointForCaAugment,
318
+ fetchDockerDaemonTlsCaPem,
319
+ augmentCaPemWithDockerDaemonChain,
320
+ caPemTrustsDockerDaemon,
321
+ writeDockerDaemonCaMarker,
322
+ resolveRemoteDockerDaemonVerify,
323
+ splitPemCertificateBlocks,
324
+ derCertToPem,
325
+ collectIssuerChainPems
326
+ };
@@ -27,6 +27,7 @@ const {
27
27
  normalizePemNewlines
28
28
  } = require('./dev-cert-helper');
29
29
  const { isSslUntrustedError, fetchInstallCa } = require('./dev-ca-install');
30
+ const { augmentAndSaveCaPemForRemoteDocker } = require('./dev-ca-docker-augment');
30
31
 
31
32
  /**
32
33
  * Read one-time issue-cert PIN from env or first non-empty line of AIFABRIX_DEV_ISSUE_PIN_FILE.
@@ -184,6 +185,10 @@ async function ensureDevCertsIfNeededForRemoteDocker() {
184
185
  const issueResponse = await requestIssueCert(ctx.baseUrl, ctx.devId, pin, csrPem, serverCaPem);
185
186
  const caPem = mergeCaPemBlocks(serverCaPem, issueResponse.caCertificate, issueResponse.ca);
186
187
  await saveDevCertMaterial(ctx.configDir, ctx.devId, issueResponse.certificate, keyPem, caPem);
188
+ await augmentAndSaveCaPemForRemoteDocker(ctx.configDir, ctx.devId, caPem, {
189
+ remoteServerUrl: ctx.baseUrl,
190
+ warn: (msg) => logger.log(chalk.yellow(` ⚠ ${msg}`))
191
+ });
187
192
  logger.log(
188
193
  formatSuccessLine('Saved TLS material to ') + chalk.cyan(ctx.certDir) + chalk.green(' for remote Docker.\n')
189
194
  );