@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.
- package/jest.isolated-projects.js +4 -0
- package/jest.projects.js +4 -0
- package/lib/api/types/dev.types.js +1 -1
- package/lib/app/down.js +3 -20
- package/lib/app/register.js +20 -18
- package/lib/app/rotate-secret.js +39 -16
- package/lib/build/index.js +12 -49
- package/lib/build/integration-docker-staging.js +35 -0
- package/lib/build/standard-docker-build.js +113 -0
- package/lib/cli/setup-app.js +4 -0
- package/lib/commands/dev-init.js +18 -0
- package/lib/commands/setup-prompts-platform-mode.js +11 -1
- package/lib/commands/setup.js +1 -0
- package/lib/commands/up-dataplane-credentials.js +88 -5
- package/lib/core/setup-platform-mode.js +16 -9
- package/lib/parameters/infra-kv-discovery.js +4 -3
- package/lib/utils/aifabrix-runtime-config-dir.js +2 -1
- package/lib/utils/compose-generate-docker-compose.js +2 -1
- package/lib/utils/config-paths.js +2 -2
- package/lib/utils/dev-ca-docker-augment.js +54 -0
- package/lib/utils/docker-daemon-tls-ca.js +326 -0
- package/lib/utils/ensure-dev-certs-for-remote-docker.js +5 -0
- package/lib/utils/promote-dev-docker-image.js +170 -0
- package/lib/utils/remote-docker-env.js +22 -7
- package/lib/utils/secrets-helpers.js +9 -14
- package/lib/utils/secrets-kv-refs.js +47 -3
- package/lib/utils/secrets-missing-error.js +1 -9
- package/lib/utils/token-manager-client-credentials.js +11 -1
- package/lib/utils/worker-sidecars.js +28 -1
- package/package.json +1 -1
- package/.temp/jest-protection-convert/run-IFN2nv/.protection/hubspot-companies.json +0 -1
- package/.temp/jest-protection-convert/run-IvmKLO/.protection/hubspot-companies.yaml +0 -17
- package/.temp/jest-protection-convert/run-SdvblF/.protection/hubspot-companies.json +0 -1
- package/.temp/jest-protection-convert/run-b51TsW/.protection/hubspot-companies.json +0 -1
- package/.temp/jest-protection-convert/run-fiLmb1/.protection/hubspot-companies.json +0 -1
- package/.temp/jest-protection-convert/run-qYGHMU/.protection/hubspot-companies.yaml +0 -17
- package/.temp/jest-protection-fixtures/hubspot-Mq5re8/hubspot-companies.yaml +0 -17
- package/.temp/jest-protection-fixtures/hubspot-f3u4YL/hubspot-companies.yaml +0 -17
- package/.temp/jest-protection-fixtures/hubspot-iQtd1O/hubspot-companies.yaml +0 -17
- package/.temp/jest-protection-fixtures/hubspot-mdmz5M/hubspot-companies.yaml +0 -17
- package/.temp/jest-protection-fixtures/hubspot-qbKYxQ/hubspot-companies.yaml +0 -17
- package/.temp/jest-protection-fixtures/hubspot-yzlJy6/hubspot-companies.yaml +0 -17
- 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
|
|
135
|
+
const credentials = await loadDataplanePipelineCredentialsFromSecretsFile();
|
|
62
136
|
if (!credentials) {
|
|
63
137
|
return false;
|
|
64
138
|
}
|
|
65
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
26
|
-
if (
|
|
26
|
+
const scanLine = activeEnvLineForKvScan(line);
|
|
27
|
+
if (!scanLine) continue;
|
|
27
28
|
let m;
|
|
28
29
|
kvPattern.lastIndex = 0;
|
|
29
|
-
while ((m = kvPattern.exec(
|
|
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
|
-
|
|
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
|
|
268
|
-
*
|
|
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
|
);
|