@starci/hfs 4.4.1 → 4.5.1
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/CHANGELOG.md +8 -0
- package/ci/cli.mjs +64 -0
- package/ci/github.mjs +41 -0
- package/ci/secret.mjs +28 -0
- package/ci/vars.mjs +33 -0
- package/ci/verify.mjs +36 -0
- package/db/backup-receipt.mjs +129 -0
- package/db/dump.mjs +66 -0
- package/db/migrate.mjs +4 -1
- package/db/reset.mjs +125 -0
- package/emit/db-types.mjs +98 -5
- package/lint/run.mjs +28 -6
- package/package.json +2 -1
- package/release/ci-red.mjs +70 -0
- package/release/gate.mjs +98 -27
- package/release/index.mjs +6 -4
- package/release/push.mjs +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +5 -5
- package/runtime/knowledge/hfs/rules.yaml +95 -4
- package/runtime/modules/kernel/failure-codes.yaml +60 -0
- package/runtime/modules/schemas/application-stacks.schema.yaml +134 -1
- package/runtime/packages/cli/src/catalog.generated.mjs +243 -20
- package/runtime/scripts/hfs/ci-declaration.mjs +102 -0
- package/runtime/scripts/hfs/rules/a15-findings.mjs +9 -0
- package/runtime/scripts/hfs/rules/app-workflow-findings.mjs +10 -0
- package/runtime/scripts/hfs/rules/ci-custody.mjs +104 -0
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +6 -19
- package/runtime/scripts/hfs/rules/fe-build-args.mjs +142 -0
- package/runtime/scripts/hfs/rules/supply-chain.mjs +40 -3
- package/runtime/scripts/hfs/rules/workflow-contexts.mjs +103 -0
- package/runtime/scripts/lib/secret-patterns.mjs +2 -2
- package/src/main.mjs +17 -6
- package/stack/context.mjs +13 -1
- package/stack/docker-transport.mjs +93 -15
- package/stack/release-store.mjs +12 -7
- package/stack/remote.mjs +14 -6
- package/stack/ssh-session.mjs +178 -0
- package/sync/ci-commands.mjs +205 -1
- package/sync/index.mjs +42 -16
- package/sync/quality-services.mjs +61 -0
- package/sync/readme-badges.mjs +44 -0
- package/sync/stack-images.mjs +29 -3
- package/templates/app/ci-workflows/github/workflows/ci.yml +33 -25
- package/templates/app/ci-workflows/github/workflows/e2e.yml +1 -0
- package/templates/app/ci-workflows/github/workflows/images.yml +25 -2
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +8 -2
- package/templates/app/ci-workflows/install-steps.yml +23 -0
- package/templates/app/ci-workflows/jest-cache.yml +8 -0
- package/templates/app/ci-workflows/lint-base.yml +16 -0
- package/templates/app/ci-workflows/sonar-steps.yml +3 -9
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +36 -36
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -1
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +25 -2
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +8 -2
- package/templates/app/gitignore +2 -0
- package/templates/app/gitignore-lite +2 -0
- package/templates/app/quality-config/codecov.yml +6 -6
- package/templates/app/quality-config/sonar-project.properties +1 -0
- package/templates/app/readme-badges/README.md +1 -0
- package/templates/app/skeleton/.github/dependabot.yml +1 -14
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +62 -3
- package/templates/app/skeleton/README.md +6 -0
- package/templates/app/skeleton-lite/.github/dependabot.yml +1 -14
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +58 -3
- package/templates/app/skeleton-lite/README.md +6 -0
- package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +53 -1
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
// docker-transport.mjs - lets the verbs that drive the docker CLI (deploy, converge, verify, rollback, prune) reach the manager from an OPERATOR
|
|
2
|
-
// machine
|
|
3
|
-
// is run with a private HOME that holds
|
|
2
|
+
// machine or CI. The command shares one POSIX control master (including remote scripts) or one Windows Docker socket tunnel. On an operator
|
|
3
|
+
// machine the key lives in the sealed members, so the docker CLI is run with a private HOME that holds the session's ssh config. Only children that did not get
|
|
4
4
|
// an explicit environment are affected (the minimal environment of `docker stack config` stays exactly as the render built it). The key lives in
|
|
5
5
|
// a private temp directory that is overwritten and removed when the verb ends; it is never in argv, a line or an error.
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { writePrivate } from './private-file.mjs';
|
|
9
9
|
import { openSealedSession } from './remote.mjs';
|
|
10
|
+
import { controlSettings, createSshSession, sessionDirectory } from './ssh-session.mjs';
|
|
11
|
+
import { failure } from './errors.mjs';
|
|
10
12
|
|
|
11
13
|
const ALIAS = 'stack-vps';
|
|
12
14
|
const forward = (file) => file.replaceAll('\\', '/');
|
|
15
|
+
const configPath = (file) => '"' + forward(file).replaceAll('"', '\\"') + '"';
|
|
16
|
+
const controlLines = (controlPath) => controlSettings(configPath(controlPath)).map((setting) => ` ${setting.replace('=', ' ')}`);
|
|
13
17
|
|
|
14
18
|
/** True when the operator's docker CLI has to be pointed at the manager by this module. */
|
|
15
19
|
function needed(ctx, config, sshCmd) {
|
|
@@ -19,35 +23,109 @@ function needed(ctx, config, sshCmd) {
|
|
|
19
23
|
return [sealed.ops, sealed.sshKey, sealed.knownHosts].every((slug) => fs.existsSync(path.join(config.secretsDir, `${slug}.enc`)));
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
function sshConfig(session) {
|
|
26
|
+
function sshConfig(session, controlPath, windows) {
|
|
23
27
|
const at = session.account.lastIndexOf('@');
|
|
24
28
|
return [
|
|
25
29
|
`Host ${ALIAS}`, ` HostName ${session.account.slice(at + 1)}`, ` User ${session.account.slice(0, at)}`,
|
|
26
|
-
` IdentityFile ${
|
|
27
|
-
` StrictHostKeyChecking ${session.strict}`, ' BatchMode yes', ' ConnectTimeout 20',
|
|
30
|
+
` IdentityFile ${configPath(session.keyFile)}`, ' IdentitiesOnly yes', ` UserKnownHostsFile ${configPath(session.knownFile)}`,
|
|
31
|
+
` StrictHostKeyChecking ${session.strict}`, ' BatchMode yes', ' ConnectTimeout 20',
|
|
32
|
+
...(windows ? [] : controlLines(controlPath)), '',
|
|
28
33
|
].join('\n');
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
/** The environment of a docker child: a private HOME with the ssh config, DOCKER_HOST, and the operator's docker config (registry logins) kept. */
|
|
32
|
-
function dockerEnvironment(ctx, session) {
|
|
37
|
+
function dockerEnvironment(ctx, session, controlPath) {
|
|
33
38
|
const home = path.join(session.dir, 'home');
|
|
34
|
-
fs.mkdirSync(
|
|
35
|
-
|
|
39
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
40
|
+
fs.chmodSync(home, 0o700);
|
|
41
|
+
fs.mkdirSync(path.join(home, '.ssh'), { recursive: true, mode: 0o700 });
|
|
42
|
+
writePrivate(ctx, path.join(home, '.ssh', 'config'), sshConfig(session, controlPath, (ctx.platform ?? process.platform) === 'win32'));
|
|
36
43
|
const operatorHome = ctx.env.HOME ?? ctx.env.USERPROFILE ?? '';
|
|
37
44
|
const dockerConfig = ctx.env.DOCKER_CONFIG ?? (operatorHome ? path.join(operatorHome, '.docker') : undefined);
|
|
38
45
|
return { ...ctx.env, HOME: home, USERPROFILE: home, DOCKER_HOST: `ssh://${ALIAS}`, ...(dockerConfig ? { DOCKER_CONFIG: dockerConfig } : {}) };
|
|
39
46
|
}
|
|
40
47
|
|
|
48
|
+
/** OpenSSH resolves its default config from the account database, so Docker needs a private PATH entry that supplies -F explicitly. */
|
|
49
|
+
function privateSshEnvironment(ctx, env, dir, controlPath) {
|
|
50
|
+
if ((ctx.platform ?? process.platform) === 'win32') {
|
|
51
|
+
const home = path.join(dir, 'home');
|
|
52
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
53
|
+
fs.chmodSync(home, 0o700);
|
|
54
|
+
const originalHome = env.HOME ?? env.USERPROFILE;
|
|
55
|
+
const dockerConfig = env.DOCKER_CONFIG ?? (originalHome ? path.join(originalHome, '.docker') : undefined);
|
|
56
|
+
return { env: { ...env, HOME: home, USERPROFILE: home, ...(dockerConfig ? { DOCKER_CONFIG: dockerConfig } : {}) }, executable: 'ssh' };
|
|
57
|
+
}
|
|
58
|
+
const originalHome = ctx.env.HOME;
|
|
59
|
+
const home = path.join(dir, 'home');
|
|
60
|
+
const config = path.join(home, '.ssh', 'config');
|
|
61
|
+
fs.mkdirSync(path.dirname(config), { recursive: true, mode: 0o700 });
|
|
62
|
+
if (!fs.existsSync(config)) {
|
|
63
|
+
const include = originalHome ? `Include ${configPath(path.join(originalHome, '.ssh', 'config'))}\n` : '';
|
|
64
|
+
writePrivate(ctx, config, ['Host *', ...controlLines(controlPath), 'Host *', include].join('\n'));
|
|
65
|
+
}
|
|
66
|
+
const executable = ctx.sshPath ?? findSsh(ctx.env.PATH);
|
|
67
|
+
const bin = path.join(home, 'bin');
|
|
68
|
+
fs.mkdirSync(bin, { mode: 0o700 });
|
|
69
|
+
const shim = path.join(bin, 'ssh');
|
|
70
|
+
writePrivate(ctx, shim, `#!/bin/sh\nexec ${posixQuote(executable)} -F ${posixQuote(config)} "$@"\n`);
|
|
71
|
+
fs.chmodSync(shim, 0o700);
|
|
72
|
+
const dockerConfig = env.DOCKER_CONFIG ?? (originalHome ? path.join(originalHome, '.docker') : undefined);
|
|
73
|
+
return { env: { ...env, HOME: home, PATH: `${bin}:${env.PATH ?? ctx.env.PATH ?? ''}`, ...(dockerConfig ? { DOCKER_CONFIG: dockerConfig } : {}) }, executable };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const posixQuote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
77
|
+
|
|
78
|
+
function findSsh(searchPath = '') {
|
|
79
|
+
const executable = searchPath.split(':').map((dir) => path.join(dir, 'ssh')).find((file) => fs.existsSync(file));
|
|
80
|
+
if (!executable) throw failure('STACK_SSH_SESSION', 'cannot locate the SSH executable');
|
|
81
|
+
return path.resolve(executable);
|
|
82
|
+
}
|
|
83
|
+
|
|
41
84
|
/** Runs `body(ctx')` where ctx'.spawn points the docker CLI at the manager when this is an operator machine; otherwise `body(ctx)`. */
|
|
42
85
|
export async function withDockerTransport(ctx, config, { sshCmd = null } = {}, body) {
|
|
43
|
-
if (
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
86
|
+
if (sshCmd) return body(ctx);
|
|
87
|
+
const sealed = needed(ctx, config, sshCmd);
|
|
88
|
+
const alias = sealed ? ALIAS : sshAlias(ctx.env.DOCKER_HOST);
|
|
89
|
+
if (!alias) return body(ctx);
|
|
90
|
+
const session = sealed ? openSealedSession(ctx, config) : null;
|
|
91
|
+
const dir = session?.dir ?? sessionDirectory();
|
|
92
|
+
let connection;
|
|
93
|
+
let cleaned = false;
|
|
94
|
+
function cleanup() {
|
|
95
|
+
if (cleaned) return;
|
|
96
|
+
cleaned = true;
|
|
97
|
+
if (session) session.cleanup();
|
|
98
|
+
else fs.rmSync(dir, { recursive: true, force: true });
|
|
99
|
+
}
|
|
48
100
|
try {
|
|
49
|
-
|
|
101
|
+
const controlPath = forward(path.join(dir, 'home', 'cm'));
|
|
102
|
+
const baseEnv = session ? dockerEnvironment(ctx, session, controlPath) : ctx.env;
|
|
103
|
+
const { env, executable } = privateSshEnvironment(ctx, baseEnv, dir, controlPath);
|
|
104
|
+
const configFile = path.join(env.HOME ?? '', '.ssh', 'config');
|
|
105
|
+
const options = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=20'];
|
|
106
|
+
const argv = session || executable !== 'ssh' ? [executable, ...options, '-F', configFile, '--', alias] : ['ssh', ...options, '--', alias];
|
|
107
|
+
connection = createSshSession(ctx, { argv, env, controlPath, cleanup });
|
|
108
|
+
const spawn = dockerSpawn(ctx, connection, env, alias);
|
|
109
|
+
for (const note of session?.notes ?? []) ctx.stderr(`${note}\n`);
|
|
110
|
+
return await body(Object.create(ctx, { spawn: { value: spawn }, stackSsh: { value: connection } }));
|
|
50
111
|
} finally {
|
|
51
|
-
|
|
112
|
+
try { await connection?.close(); } finally {
|
|
113
|
+
cleanup();
|
|
114
|
+
}
|
|
52
115
|
}
|
|
53
116
|
}
|
|
117
|
+
|
|
118
|
+
/** Only SSH Docker endpoints belong to this connection; TCP, unix and explicitly supplied child environments keep their behavior. */
|
|
119
|
+
function sshAlias(host) {
|
|
120
|
+
if (!host?.startsWith('ssh://')) return null;
|
|
121
|
+
const endpoint = new URL(host);
|
|
122
|
+
if (endpoint.port || endpoint.pathname && endpoint.pathname !== '/') return null;
|
|
123
|
+
return endpoint.username ? `${decodeURIComponent(endpoint.username)}@${endpoint.hostname}` : endpoint.hostname;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function dockerSpawn(ctx, connection, env, alias) {
|
|
127
|
+
return (command, args, options = {}) => {
|
|
128
|
+
if (command !== 'docker' || options.env !== undefined) return ctx.spawn(command, args, options);
|
|
129
|
+
return ctx.spawn(command, args, { ...options, env: { ...env, DOCKER_HOST: connection.dockerHost(alias) } });
|
|
130
|
+
};
|
|
131
|
+
}
|
package/stack/release-store.mjs
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
// the remote shell. The record holds names and digests, never a secret value. Files travel as text on stdin / stdout.
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
-
import { eachInOrder, mapInOrder } from '../runtime/scripts/lib/in-order.mjs';
|
|
9
8
|
import { firstLine, lines as splitLines } from './context.mjs';
|
|
10
9
|
import { isReleaseId } from './digest.mjs';
|
|
11
10
|
import { failure, refusal } from './errors.mjs';
|
|
@@ -56,9 +55,8 @@ export function createReleaseStore(config, ssh) {
|
|
|
56
55
|
async store(dir, id) {
|
|
57
56
|
const target = dirOf(id);
|
|
58
57
|
const contents = FILES.map((name) => [name, readLocal(dir, name)]);
|
|
59
|
-
|
|
60
|
-
await
|
|
61
|
-
await run(`set -e; d="${target}"; chmod 440 "$d/stack.yaml" "$d/release.json"; chmod 550 "$d"`);
|
|
58
|
+
const input = contents.map(([, text]) => Buffer.from(text, 'utf8').toString('base64')).join('\n') + '\n';
|
|
59
|
+
await run(`set -e; d="${target}"; if [ -d "$d" ]; then chmod -R u+w "$d"; else mkdir -p "$d"; fi; IFS= read -r stack; IFS= read -r release; printf '%s' "$stack" | base64 -d > "$d/stack.yaml"; printf '%s' "$release" | base64 -d > "$d/release.json"; chmod 440 "$d/stack.yaml" "$d/release.json"; chmod 550 "$d"`, input);
|
|
62
60
|
},
|
|
63
61
|
/** The release was verified: previous := current, current := id. */
|
|
64
62
|
async accept(id) {
|
|
@@ -72,15 +70,22 @@ export function createReleaseStore(config, ssh) {
|
|
|
72
70
|
/** Downloads a stored release (both files) into `targetDir` for a rollback or an audit. */
|
|
73
71
|
async fetch(id, targetDir) {
|
|
74
72
|
const source = dirOf(id);
|
|
75
|
-
const
|
|
73
|
+
const out = await run(`set -e; for f in stack.yaml release.json; do test -r "${source}/$f"; base64 < "${source}/$f" | tr -d '\\n'; printf '\\n'; done`);
|
|
74
|
+
const frames = out.split('\n');
|
|
75
|
+
if (frames.length !== 3 || frames[2] !== '' || frames.slice(0, 2).some((frame) => !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(frame))) throw failure('STACK_RELEASE_SSH', 'invalid release response framing');
|
|
76
|
+
const files = FILES.map((name, index) => [name, Buffer.from(frames[index], 'base64').toString('utf8')]);
|
|
76
77
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
77
78
|
for (const [name, text] of files) fs.writeFileSync(path.join(targetDir, name), text);
|
|
78
79
|
return targetDir;
|
|
79
80
|
},
|
|
80
81
|
/** The stored releases, newest first: [{ id, current, previous }]. */
|
|
81
82
|
async list() {
|
|
82
|
-
const [
|
|
83
|
-
const
|
|
83
|
+
const pointerScript = (name) => `if [ -L "${base}/${name}" ]; then basename "$(readlink "${base}/${name}")"; else printf '\\n'; fi`;
|
|
84
|
+
const response = await run(`${pointerScript('current')}; ${pointerScript('previous')}; cd "${base}/releases" 2>/dev/null && ls -1t || true`);
|
|
85
|
+
const [current, previous, ...entries] = response.split('\n');
|
|
86
|
+
const now = isReleaseId(current) ? current : '';
|
|
87
|
+
const before = isReleaseId(previous) ? previous : '';
|
|
88
|
+
const out = entries.join('\n');
|
|
84
89
|
return splitLines(out).filter(isReleaseId).map((id) => ({ id, current: id === now, previous: id === before }));
|
|
85
90
|
},
|
|
86
91
|
};
|
package/stack/remote.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import path from 'node:path';
|
|
|
13
13
|
import { tool } from './context.mjs';
|
|
14
14
|
import { restrictToUser, writePrivate } from './private-file.mjs';
|
|
15
15
|
import { refusal } from './errors.mjs';
|
|
16
|
+
import { createSshSession, sessionDirectory } from './ssh-session.mjs';
|
|
16
17
|
|
|
17
18
|
const ACCOUNT = /^[A-Za-z0-9._@-]+$/u;
|
|
18
19
|
const CI_VARIABLES = ['VPS_SSH_KEY', 'VPS_KNOWN_HOSTS', 'VPS_HOST', 'VPS_USER'];
|
|
@@ -33,11 +34,11 @@ export const remoteDocker = (run, args) => run(['docker', ...args.map(shellQuote
|
|
|
33
34
|
|
|
34
35
|
const noop = () => {};
|
|
35
36
|
|
|
36
|
-
function makeRunner(ctx, argv, { cleanup = noop, notes = [] } = {}) {
|
|
37
|
+
function makeRunner(ctx, argv, { cleanup = noop, notes = [], connection } = {}) {
|
|
37
38
|
const [command, ...head] = argv;
|
|
38
39
|
const run = (remote, { input } = {}) => {
|
|
39
40
|
const options = input === undefined ? {} : { input: lfOnly(input) };
|
|
40
|
-
const result = tool(ctx, command, [...head, lfOnly(remote)], options);
|
|
41
|
+
const result = connection ? connection.remote(lfOnly(remote), options) : tool(ctx, command, [...head, lfOnly(remote)], options);
|
|
41
42
|
if (result.error) return { status: 127, stdout: '', stderr: `cannot run ${command}: ${result.error.message}` };
|
|
42
43
|
return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr };
|
|
43
44
|
};
|
|
@@ -119,14 +120,21 @@ export function openSealedSession(ctx, config) {
|
|
|
119
120
|
function sealedRunner(ctx, config) {
|
|
120
121
|
const session = openSealedSession(ctx, config);
|
|
121
122
|
const argv = ['ssh', '-T', '-i', session.keyFile, '-o', 'IdentitiesOnly=yes', '-o', `UserKnownHostsFile=${session.knownFile}`, '-o', `StrictHostKeyChecking=${session.strict}`, '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=20', session.account];
|
|
122
|
-
return
|
|
123
|
+
return sessionRunner(ctx, argv, session.dir, session.cleanup, session.notes);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function sessionRunner(ctx, argv, dir, cleanup, notes = []) {
|
|
127
|
+
const connection = createSshSession(ctx, { argv, controlPath: path.join(dir, 'cm').replaceAll('\\', '/'), cleanup });
|
|
128
|
+
return makeRunner(ctx, argv, { connection, cleanup: connection.close, notes });
|
|
123
129
|
}
|
|
124
130
|
|
|
125
131
|
/** The ssh runner of one invocation (see the top of the file). */
|
|
126
132
|
function sshRunner(ctx, config, { sshCmd = null } = {}) {
|
|
133
|
+
if (ctx.stackSsh && !sshCmd) return makeRunner(ctx, ['ssh'], { connection: ctx.stackSsh });
|
|
127
134
|
if (sshCmd) return makeRunner(ctx, sshCommandArgv(sshCmd));
|
|
128
135
|
if (config.remote.sealed && ctx.env.STARCI_STACK_SEALED_SSH === '1') return sealedRunner(ctx, config);
|
|
129
|
-
|
|
136
|
+
const dir = sessionDirectory();
|
|
137
|
+
return sessionRunner(ctx, ['ssh', ...SSH_OPTIONS, config.remote.host], dir, () => fs.rmSync(dir, { recursive: true, force: true }));
|
|
130
138
|
}
|
|
131
139
|
|
|
132
140
|
/**
|
|
@@ -150,7 +158,7 @@ export async function withRunner(ctx, config, options, deps, body) {
|
|
|
150
158
|
try {
|
|
151
159
|
return await body(run, run.notes ?? []);
|
|
152
160
|
} finally {
|
|
153
|
-
run.cleanup?.();
|
|
161
|
+
await run.cleanup?.();
|
|
154
162
|
}
|
|
155
163
|
}
|
|
156
164
|
|
|
@@ -174,7 +182,7 @@ function ciInputs(ctx) {
|
|
|
174
182
|
const sshBlock = (alias, inputs) => [
|
|
175
183
|
'', `Host ${alias}`, ` HostName ${inputs.host}`, ` User ${inputs.user}`, ' IdentityFile ~/.ssh/deploy_key',
|
|
176
184
|
' StrictHostKeyChecking yes', ' BatchMode yes', ' ConnectTimeout 20', ' ServerAliveInterval 30',
|
|
177
|
-
' ControlMaster auto', ' ControlPath ~/.ssh/cm-%C', ' ControlPersist
|
|
185
|
+
' ControlMaster auto', ' ControlPath ~/.ssh/cm-%C', ' ControlPersist 60', '',
|
|
178
186
|
].join('\n');
|
|
179
187
|
|
|
180
188
|
/** CI transport: pinned key, known_hosts, ssh config block and DOCKER_HOST=ssh://<alias> in $GITHUB_ENV. */
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// ssh-session.mjs - one command-owned connection; all processes use ctx.spawn. Windows prefers a Unix socket in private 0700 HOME
|
|
2
|
+
// when runtime ssh -V reports OpenSSH >= 6.7 and a local AF_UNIX Docker CLI probe succeeds. Otherwise a fresh ephemeral TCP
|
|
3
|
+
// port is asserted to bind only 127.0.0.1 and never reaches files or logs. Finally and SIGINT/SIGTERM close the tunnel.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { failure } from './errors.mjs';
|
|
8
|
+
import { restrictToUser } from './private-file.mjs';
|
|
9
|
+
|
|
10
|
+
const FREE_PORT = "const net=require('node:net');const s=net.createServer();s.on('error',()=>process.exit(1));s.listen(0,'127.0.0.1',()=>{console.log(s.address().port);s.close();});";
|
|
11
|
+
const WAIT_PORT = "const net=require('node:net');const deadline=Date.now()+20000;function probe(){const s=net.connect({host:'127.0.0.1',port:Number(process.argv[1])});s.setTimeout(200);s.on('connect',()=>{s.destroy();process.exit(0);});function retry(){s.destroy();if(Date.now()>=deadline)process.exit(1);else setTimeout(probe,50);}s.on('error',retry);s.on('timeout',retry);}probe();";
|
|
12
|
+
|
|
13
|
+
const WAIT_SOCKET = WAIT_PORT.replace("{host:'127.0.0.1',port:Number(process.argv[1])}", '{path:process.argv[1]}');
|
|
14
|
+
const UNIX_PROBE = String.raw`
|
|
15
|
+
const http = require('node:http');
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const { spawn } = require('node:child_process');
|
|
18
|
+
const socket = process.argv[1];
|
|
19
|
+
let child;
|
|
20
|
+
function finish(code) {
|
|
21
|
+
child?.kill();
|
|
22
|
+
server.close();
|
|
23
|
+
try { fs.unlinkSync(socket); } catch {}
|
|
24
|
+
process.exit(code);
|
|
25
|
+
}
|
|
26
|
+
const server = http.createServer((req, res) => {
|
|
27
|
+
res.setHeader('Api-Version', '1.47');
|
|
28
|
+
res.setHeader('Content-Type', 'application/json');
|
|
29
|
+
res.end(req.url.endsWith('/_ping') ? 'OK' : JSON.stringify({ Version: '27.0.0', ApiVersion: '1.47', MinAPIVersion: '1.24', Os: 'linux', Arch: 'amd64' }));
|
|
30
|
+
});
|
|
31
|
+
server.on('error', () => finish(1));
|
|
32
|
+
server.listen(socket, () => {
|
|
33
|
+
child = spawn('docker', ['--host', 'unix://' + socket.replaceAll('\\', '/'), 'version', '--format', '{{.Server.Version}}'], { stdio: 'ignore', windowsHide: true });
|
|
34
|
+
child.on('error', () => finish(1));
|
|
35
|
+
child.on('exit', code => finish(code === 0 ? 0 : 1));
|
|
36
|
+
});
|
|
37
|
+
setTimeout(() => finish(1), 5000);
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
/** TCP Docker forwarding must never expose the manager on a non-loopback interface. */
|
|
41
|
+
function loopbackForward(bind, port, socket) {
|
|
42
|
+
if (bind !== '127.0.0.1') throw failure('STACK_SSH_SESSION', 'the Docker tunnel must bind 127.0.0.1');
|
|
43
|
+
return `${bind}:${port}:${socket}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function unixEndpoint(ctx, command, env) {
|
|
47
|
+
const version = ctx.spawn(command, ['-V'], { env });
|
|
48
|
+
const match = /OpenSSH(?:_for_Windows)?_(\d+)\.(\d+)/u.exec(`${version.stderr ?? ''} ${version.stdout ?? ''}`);
|
|
49
|
+
if (!successful(version) || !match || Number(match[1]) < 6 || Number(match[1]) === 6 && Number(match[2]) < 7) return null;
|
|
50
|
+
if (!env.HOME) return null;
|
|
51
|
+
fs.mkdirSync(env.HOME, { recursive: true, mode: 0o700 });
|
|
52
|
+
fs.chmodSync(env.HOME, 0o700);
|
|
53
|
+
restrictToUser(ctx, env.HOME, '(OI)(CI)F');
|
|
54
|
+
const probe = path.join(env.HOME, 'probe.sock');
|
|
55
|
+
try {
|
|
56
|
+
if (!successful(ctx.spawn(process.execPath, ['-e', UNIX_PROBE, probe], { env, timeout: 7000 }))) return null;
|
|
57
|
+
} finally {
|
|
58
|
+
fs.rmSync(probe, { force: true });
|
|
59
|
+
}
|
|
60
|
+
return path.join(env.HOME, 'docker.sock').replaceAll('\\', '/');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The same multiplexing settings belong to the master, remote commands and Docker's SSH alias. */
|
|
64
|
+
export function controlSettings(controlPath) {
|
|
65
|
+
return ['ControlMaster=auto', `ControlPath=${controlPath}`, 'ControlPersist=60'];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function successful(result) {
|
|
69
|
+
return !result.error && result.status === 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function freePort(ctx) {
|
|
73
|
+
const result = ctx.spawn(process.execPath, ['-e', FREE_PORT]);
|
|
74
|
+
const port = Number(result.stdout?.trim());
|
|
75
|
+
if (!successful(result) || !Number.isInteger(port) || port < 1 || port > 65535) throw failure('STACK_SSH_SESSION', 'cannot allocate a loopback port');
|
|
76
|
+
return port;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Waits for the tunnel child to exit before any private credentials are removed. */
|
|
80
|
+
function stopTunnel(child) {
|
|
81
|
+
if (!child || child.spawnError || child.exitCode !== null && child.exitCode !== undefined || child.signalCode) return;
|
|
82
|
+
if (!child.once) { child.kill(); return; }
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), 2000);
|
|
85
|
+
child.once('close', () => { clearTimeout(timer); resolve(); });
|
|
86
|
+
child.kill();
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function closeTunnel(child, cleanup) {
|
|
91
|
+
try { await stopTunnel(child); } finally { cleanup(); }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Creates a lazy connection; no SSH is spawned until a Docker query or remote command needs it. */
|
|
95
|
+
export function createSshSession(ctx, { argv, env = ctx.env, controlPath, socket = '/var/run/docker.sock', cleanup = () => {} }) {
|
|
96
|
+
const windows = (ctx.platform ?? process.platform) === 'win32';
|
|
97
|
+
const [command, ...head] = argv;
|
|
98
|
+
const controls = windows ? [] : controlSettings(controlPath).flatMap((setting) => ['-o', setting]);
|
|
99
|
+
let started = false;
|
|
100
|
+
let ready = false;
|
|
101
|
+
let child;
|
|
102
|
+
let host;
|
|
103
|
+
let closing;
|
|
104
|
+
const signalHandlers = new Map();
|
|
105
|
+
let closed = false;
|
|
106
|
+
|
|
107
|
+
function start() {
|
|
108
|
+
if (closed) throw failure('STACK_SSH_SESSION', 'the command session is already closed');
|
|
109
|
+
if (ready) return;
|
|
110
|
+
if (started) throw failure('STACK_SSH_SESSION', 'the SSH connection failed to open');
|
|
111
|
+
started = true;
|
|
112
|
+
if (windows) {
|
|
113
|
+
const localSocket = unixEndpoint(ctx, command, env);
|
|
114
|
+
const port = localSocket ? null : freePort(ctx);
|
|
115
|
+
const forward = localSocket ? `./docker.sock:${socket}` : loopbackForward('127.0.0.1', port, socket);
|
|
116
|
+
host = localSocket ? `unix://${localSocket}` : `tcp://127.0.0.1:${port}`;
|
|
117
|
+
child = ctx.spawn(command, ['-N', '-o', 'ExitOnForwardFailure=yes', '-o', 'GatewayPorts=no', ...(localSocket ? ['-o', 'StreamLocalBindMask=0177'] : []), '-L', forward, ...head], { env, cwd: localSocket ? env.HOME : undefined, background: true });
|
|
118
|
+
if (typeof child?.kill !== 'function') {
|
|
119
|
+
child = undefined;
|
|
120
|
+
throw failure('STACK_SSH_SESSION', 'cannot start the Docker SSH tunnel');
|
|
121
|
+
}
|
|
122
|
+
installSignals();
|
|
123
|
+
const probeArgs = localSocket ? ['-e', WAIT_SOCKET, localSocket] : ['-e', WAIT_PORT, String(port)];
|
|
124
|
+
const probe = ctx.spawn(process.execPath, probeArgs, { env, timeout: 25000 });
|
|
125
|
+
if (!successful(probe)) throw failure('STACK_SSH_SESSION', 'the Docker SSH tunnel did not become ready');
|
|
126
|
+
} else {
|
|
127
|
+
const result = ctx.spawn(command, ['-MNf', ...controls, ...head], { env });
|
|
128
|
+
if (!successful(result)) throw failure('STACK_SSH_SESSION', 'cannot open the SSH control master');
|
|
129
|
+
}
|
|
130
|
+
ready = true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function remote(remoteCommand, options = {}) {
|
|
134
|
+
// Windows cannot multiplex exec channels; Docker queries share the tunnel and remote scripts remain one call per step.
|
|
135
|
+
if (!windows) start();
|
|
136
|
+
return ctx.spawn(command, [...controls, ...head, remoteCommand], { ...options, env });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function dockerHost(alias) {
|
|
140
|
+
start();
|
|
141
|
+
return windows ? host : `ssh://${alias}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function installSignals() {
|
|
145
|
+
for (const [signal, code] of [['SIGINT', 130], ['SIGTERM', 143]]) {
|
|
146
|
+
const handler = () => { Promise.resolve(close()).finally(() => process.exit(code)); };
|
|
147
|
+
signalHandlers.set(signal, handler);
|
|
148
|
+
process.once(signal, handler);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function finish() {
|
|
153
|
+
for (const [signal, handler] of signalHandlers) process.removeListener(signal, handler);
|
|
154
|
+
signalHandlers.clear();
|
|
155
|
+
cleanup();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function close() {
|
|
159
|
+
if (closed) return closing;
|
|
160
|
+
closed = true;
|
|
161
|
+
if (windows && child) {
|
|
162
|
+
closing = closeTunnel(child, finish);
|
|
163
|
+
return closing;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
if (!windows && started) ctx.spawn(command, ['-O', 'exit', ...controls, ...head], { env });
|
|
167
|
+
} finally {
|
|
168
|
+
finish();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { remote, dockerHost, close };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A short private directory for an invocation whose SSH credentials already live in the operator or CI HOME. */
|
|
176
|
+
export function sessionDirectory() {
|
|
177
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'sc-'));
|
|
178
|
+
}
|