log in to registries over TLS on remote HTTPS environments

This commit is contained in:
Jarek Krochmalski committed 2026-09-13 14:50:02 +02:00
1 parent 0e0ec6f122
commit 2810a97f39
4 files changed
+90 -8

No files matched your search

+17
View File
@@ -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'
};
}
+36 -8
View File
@@ -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<void> {
// 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<void> {
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(
+19
View File
@@ -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');
});
});
+18
View File
@@ -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/);
});
});