From 2810a97f39b3e5323ec762cc5ba4a3c3541facd9 Mon Sep 17 00:00:00 2001 From: Jarek Krochmalski Date: Sun, 13 Sep 2026 14:50:02 +0200 Subject: [PATCH] log in to registries over TLS on remote HTTPS environments --- src/lib/server/docker-tls-env.ts | 17 +++++++++++ src/lib/server/stacks.ts | 44 ++++++++++++++++++++++----- tests/docker-tls-env.test.ts | 19 ++++++++++++ tests/redeploy-force-recreate.test.ts | 18 +++++++++++ 4 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 src/lib/server/docker-tls-env.ts create mode 100644 tests/docker-tls-env.test.ts diff --git a/src/lib/server/docker-tls-env.ts b/src/lib/server/docker-tls-env.ts new file mode 100644 index 0000000..e5ba2af --- /dev/null +++ b/src/lib/server/docker-tls-env.ts @@ -0,0 +1,17 @@ +/** + * The Docker CLI TLS env vars for talking to an HTTPS daemon, given a directory that + * already holds ca/cert/key.pem. Used by both the compose spawn and the registry-login + * spawn so `docker login` speaks TLS to a mTLS proxy on :2376 instead of plaintext HTTP + * (#1557). Pure so the DOCKER_TLS_VERIFY mapping is unit-testable without spawning docker. + */ +export function dockerTlsEnv(certDir: string, skipVerify?: boolean): { + DOCKER_TLS: string; + DOCKER_CERT_PATH: string; + DOCKER_TLS_VERIFY: string; +} { + return { + DOCKER_TLS: '1', + DOCKER_CERT_PATH: certDir, + DOCKER_TLS_VERIFY: skipVerify ? '0' : '1' + }; +} diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index f025457..3906965 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -11,6 +11,7 @@ import { spawn as nodeSpawn } from 'node:child_process'; import type { ChildProcess } from 'node:child_process'; import { redactSecretVars } from './secret-redact'; import { collectProcess } from './process-output-core'; +import { dockerTlsEnv } from './docker-tls-env'; import { makeLineForwarder, makeRedactedLineSink } from './secret-redaction'; import { applyFileDeletions, @@ -1045,7 +1046,15 @@ async function checkFlatLocalStackNameCollision(stackName: string, envId?: numbe * Login to all configured Docker registries before running compose commands. * This ensures that `docker compose up` can pull images from private registries. */ -async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]', apiVersion?: string): Promise { +// TLS material for a registry login against an HTTPS Docker daemon. `certDir` is the +// temp dir compose already wrote ca/cert/key.pem into, reused so certs aren't written +// twice; `skipVerify` mirrors the compose spawn's DOCKER_TLS_VERIFY. +interface LoginTlsOptions { + certDir: string; + skipVerify?: boolean; +} + +async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]', apiVersion?: string, tls?: LoginTlsOptions): Promise { const { getRegistries } = await import('./db.js'); const registries = await getRegistries(); @@ -1061,6 +1070,11 @@ async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]', api if (apiVersion) { spawnEnv.DOCKER_API_VERSION = apiVersion; } + // Speak TLS to an HTTPS daemon (mTLS proxy on :2376), same material compose uses - + // otherwise `docker login` talks plaintext HTTP and the terminator rejects it (#1557). + if (tls) { + Object.assign(spawnEnv, dockerTlsEnv(tls.certDir, tls.skipVerify)); + } for (const reg of registries) { if (!reg.username || !reg.password) { @@ -1360,10 +1374,8 @@ async function executeLocalCompose( if (cleanedKey) writeFileSync(join(tlsCertDir, 'key.pem'), cleanedKey); } - // Set Docker TLS environment variables - spawnEnv.DOCKER_TLS = '1'; - spawnEnv.DOCKER_CERT_PATH = tlsCertDir; - spawnEnv.DOCKER_TLS_VERIFY = tlsConfig.skipVerify ? '0' : '1'; + // Set Docker TLS environment variables (shared with the registry-login spawn, #1557) + Object.assign(spawnEnv, dockerTlsEnv(tlsCertDir, tlsConfig.skipVerify)); console.log(`${logPrefix} TLS enabled: DOCKER_CERT_PATH=${tlsCertDir}, DOCKER_TLS_VERIFY=${spawnEnv.DOCKER_TLS_VERIFY}`); } @@ -1457,7 +1469,8 @@ async function executeLocalCompose( // Login to registries before pulling images if (operation === 'up' || operation === 'pull') { - await loginToRegistries(dockerHost, logPrefix, spawnEnv.DOCKER_API_VERSION); + await loginToRegistries(dockerHost, logPrefix, spawnEnv.DOCKER_API_VERSION, + tlsCertDir ? { certDir: tlsCertDir, skipVerify: tlsConfig?.skipVerify } : undefined); } try { @@ -2414,12 +2427,27 @@ export async function redeployStackFromDir( } const envPath = join(stackDir, '.env'); const hasEnv = existsSync(envPath); - const envVars = hasEnv ? parseEnvFileContent(readFileSync(envPath, 'utf-8'), stackName) : undefined; + const envFileContent = hasEnv ? readFileSync(envPath, 'utf-8') : undefined; + let envVars = envFileContent ? parseEnvFileContent(envFileContent, stackName) : undefined; // Secret env vars are stored encrypted in the DB and deliberately never written // to the snapshot's .env, so the extracted dir has no copy of them. Load them // from the DB and inject them like every other compose path does (#1329) — // otherwise a restored stack comes up with its secrets interpolating to "". - const secretVars = await getSecretEnvVarsAsRecord(stackName, envId); + let secretVars = await getSecretEnvVarsAsRecord(stackName, envId); + // Resolve provider references (op://, keepass://, azurekv://, pass://, bulk pull) the + // same way the start/restart/deploy paths do - this path loaded raw DB secrets, so a + // secret-marked reference would otherwise reach the container as the literal string. + const source = await getStackSource(stackName, envId ?? undefined); + const resolved = await resolveProviderEnvVars( + { ...(envVars ?? {}) }, + { ...secretVars }, + `[Stack:${stackName}]`, + source?.secretProviderId, + envFileContent, + { stackName, envId: envId ?? undefined } + ); + envVars = resolved.dbNonSecretVars; + secretVars = resolved.secretVars; // For Hawser, ship the entire tree (compose + include:d files + sidecars + .env). const stackFiles = await readDirFilesAsMap(stackDir); return await executeComposeCommand( diff --git a/tests/docker-tls-env.test.ts b/tests/docker-tls-env.test.ts new file mode 100644 index 0000000..87f27a5 --- /dev/null +++ b/tests/docker-tls-env.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'bun:test'; +import { dockerTlsEnv } from '../src/lib/server/docker-tls-env'; + +describe('dockerTlsEnv (#1557 - login/compose speak TLS to an HTTPS daemon)', () => { + it('enables TLS and points at the cert dir', () => { + const env = dockerTlsEnv('/data/tmp/tls-x'); + expect(env.DOCKER_TLS).toBe('1'); + expect(env.DOCKER_CERT_PATH).toBe('/data/tmp/tls-x'); + }); + + it('verifies the server cert by default (skipVerify unset -> DOCKER_TLS_VERIFY=1)', () => { + expect(dockerTlsEnv('/d').DOCKER_TLS_VERIFY).toBe('1'); + expect(dockerTlsEnv('/d', false).DOCKER_TLS_VERIFY).toBe('1'); + }); + + it('skips verification when asked (skipVerify=true -> DOCKER_TLS_VERIFY=0)', () => { + expect(dockerTlsEnv('/d', true).DOCKER_TLS_VERIFY).toBe('0'); + }); +}); diff --git a/tests/redeploy-force-recreate.test.ts b/tests/redeploy-force-recreate.test.ts index 1a6c459..a1df4c3 100644 --- a/tests/redeploy-force-recreate.test.ts +++ b/tests/redeploy-force-recreate.test.ts @@ -47,4 +47,22 @@ describe('restore redeploy force-recreates', () => { // Whitespace-tolerant: `forceRecreate: true` in the options object. expect(body).toMatch(/forceRecreate\s*:\s*true/); }); + + // A restore loads raw DB secrets; if this path skips the provider-resolution choke + // point, a secret-marked op://... reaches the container as the literal reference + // string. Guard the wiring at the source level (a real behavioral test needs a + // local dev server + a fake provider CLI, which the CI shard can't host): the + // resolver must be CALLED, its result ASSIGNED BACK to the vars, and it must run + // BEFORE the deploy - so a refactor that drops the result or reorders the call fails. + test('redeployStackFromDir resolves provider references and uses the result before deploying', () => { + const call = body.indexOf('resolveProviderEnvVars('); + const deploy = body.indexOf('executeComposeCommand('); + expect(call).toBeGreaterThan(-1); + expect(deploy).toBeGreaterThan(-1); + // resolution happens before the compose command runs + expect(call).toBeLessThan(deploy); + // the resolved maps are assigned back (result not discarded) + expect(body).toMatch(/=\s*resolved\.dbNonSecretVars/); + expect(body).toMatch(/=\s*resolved\.secretVars/); + }); });