@ours.network/install 1.2.1-nightly.4 → 1.2.1-nightly.40
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/GATEWAY_SETUP.md +178 -0
- package/README.md +453 -9
- package/ROOTLESS_PODMAN_VALIDATION.md +210 -0
- package/assets/Dockerfile +14 -12
- package/assets/Dockerfile.gateway +5 -0
- package/assets/docker-compose.yaml +8 -1
- package/assets/fleet-settings.example.json +48 -0
- package/assets/gateway-entrypoint.sh +37 -0
- package/assets/release-lock.json +1139 -4470
- package/assets/release.json +37 -19
- package/assets/scripts/build/build-common.mjs +2 -1
- package/assets/scripts/build/build-sdk.mjs +6 -1
- package/assets/scripts/maintenance/build-context.mjs +1 -1
- package/assets/scripts/maintenance/daemon-owner.mjs +15 -0
- package/assets/scripts/maintenance/docker-layout-conversion.mjs +7 -3
- package/assets/scripts/maintenance/release-graph.mjs +18 -1
- package/assets/scripts/maintenance/state-operation.mjs +12 -2
- package/assets/scripts/runtime/README.md +7 -0
- package/assets/scripts/runtime/client-setup.mjs +80 -6
- package/assets/scripts/runtime/dependency-ready.mjs +13 -0
- package/assets/scripts/runtime/diagnostics.mjs +72 -0
- package/assets/scripts/runtime/entrypoint.sh +5 -1
- package/assets/scripts/runtime/health-messenger.mjs +7 -6
- package/assets/scripts/runtime/notifications-gateway.mjs +90 -0
- package/assets/scripts/runtime/recover.mjs +165 -0
- package/assets/scripts/runtime/start-cowork.sh +5 -0
- package/assets/scripts/runtime/start-messenger.sh +29 -4
- package/assets/scripts/runtime/start-telegram.sh +5 -0
- package/assets/scripts/runtime/supervise.mjs +35 -0
- package/assets/sources.json +54 -28
- package/install.mjs +7 -2
- package/lib/boot-readiness.mjs +43 -0
- package/lib/build-transition.mjs +3 -0
- package/lib/client-cli.mjs +127 -0
- package/lib/container-engine.mjs +127 -0
- package/lib/diagnostics.mjs +1 -0
- package/lib/docker-conversion-runtime.mjs +3 -2
- package/lib/docker-layout-installation.mjs +2 -1
- package/lib/docker-recovery.mjs +55 -0
- package/lib/docker-runtime-repair.mjs +160 -0
- package/lib/effects.mjs +323 -132
- package/lib/gateway-transition.mjs +87 -0
- package/lib/gateway.mjs +158 -0
- package/lib/legacy-migration.mjs +13 -2
- package/lib/orchestrate-uninstall.mjs +18 -6
- package/lib/orchestrate.mjs +193 -43
- package/lib/plan.mjs +27 -11
- package/lib/podman-preflight.mjs +40 -0
- package/lib/server-onboarding.mjs +164 -16
- package/lib/server-ports.mjs +17 -0
- package/lib/setup-options.mjs +99 -28
- package/lib/setup.mjs +35 -17
- package/lib/target.mjs +24 -8
- package/lib/uninstall.mjs +2 -2
- package/lib/usage.mjs +21 -7
- package/lib/workspace-setup.mjs +83 -0
- package/package.json +4 -2
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const engineName = record => record.containerEngine === undefined ? 'docker' : record.containerEngine;
|
|
5
|
+
const path = value => typeof value === 'string' && isAbsolute(value) && resolve(value) === value && !/[\x00-\x1f]/.test(value);
|
|
6
|
+
export function validateContainerEngine(record) {
|
|
7
|
+
const engine = engineName(record);
|
|
8
|
+
if (!['docker', 'podman'].includes(engine) || (record.mode !== 'docker' && (record.containerEngine !== undefined || record.containerBinding !== undefined))) throw new Error('Invalid container engine selection');
|
|
9
|
+
const b = record.containerBinding;
|
|
10
|
+
if (engine === 'docker') {
|
|
11
|
+
if (b !== undefined) throw new Error('Unexpected Docker backend binding');
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (!b || b.version !== 1 || !Number.isInteger(b.uid) || b.uid <= 0 || !path(b.graphRoot) || !path(b.runRoot)
|
|
15
|
+
|| !/^[a-f0-9]{64}$/.test(b.namespaceDigest ?? '') || !path(b.socket) || !path(b.provider) || typeof b.driver !== 'string' || !/^[a-z0-9]+$/.test(b.driver)
|
|
16
|
+
|| Object.keys(b).some(k => !['version', 'uid', 'graphRoot', 'runRoot', 'socket', 'provider', 'driver', 'namespaceDigest'].includes(k))) throw new Error('Invalid rootless Podman backend binding');
|
|
17
|
+
}
|
|
18
|
+
function podmanEnvironment(record, env, uid) {
|
|
19
|
+
validateContainerEngine(record);
|
|
20
|
+
const b = record.containerBinding;
|
|
21
|
+
if (uid !== b.uid) throw new Error('Podman installation belongs to another rootless owner');
|
|
22
|
+
for (const key of ['CONTAINER_HOST', 'CONTAINER_CONNECTION', 'CONTAINERS_STORAGE_CONF', 'CONTAINERS_CONF', 'PODMAN_USERNS', 'STORAGE_DRIVER', 'STORAGE_OPTS']) {
|
|
23
|
+
if (env[key]) throw new Error(`${key} conflicts with the retained local Podman backend`);
|
|
24
|
+
}
|
|
25
|
+
if ((env.OURS_PODMAN_SOCKET && env.OURS_PODMAN_SOCKET !== b.socket) || env.DOCKER_CONTEXT || (env.DOCKER_HOST && env.DOCKER_HOST !== `unix://${b.socket}`)
|
|
26
|
+
|| (env.PODMAN_COMPOSE_PROVIDER && env.PODMAN_COMPOSE_PROVIDER !== b.provider)) throw new Error('Environment conflicts with the retained Podman backend/provider');
|
|
27
|
+
return { DOCKER_CONTEXT: undefined, DOCKER_HOST: `unix://${b.socket}`, PODMAN_COMPOSE_PROVIDER: b.provider, PODMAN_COMPOSE_WARNING_LOGS: 'false' };
|
|
28
|
+
}
|
|
29
|
+
/** The only container command boundary. Legacy records keep their Docker path. */
|
|
30
|
+
export async function runContainer(effects, record, args, options = {}) {
|
|
31
|
+
if (engineName(record) === 'docker') return effects.run('docker', args, options);
|
|
32
|
+
const env = { ...(effects.env ?? {}), ...(options.env ?? {}) };
|
|
33
|
+
const selected = podmanEnvironment(record, env, process.getuid?.());
|
|
34
|
+
const b = record.containerBinding;
|
|
35
|
+
if (args[0] === 'build' && !args.some(arg => arg === '--format' || arg.startsWith('--format='))) args = ['build', '--format', 'docker', ...args.slice(1)];
|
|
36
|
+
// Docker reports a missing object as 1; Podman inspect uses 125. Probe exact
|
|
37
|
+
// existence separately so transport/permission failures are never absence.
|
|
38
|
+
if (['image', 'volume'].includes(args[0]) && args[1] === 'inspect' && options.allowCodes?.includes(1)) {
|
|
39
|
+
const found = await runContainer(effects, record, [args[0], 'exists', args.at(-1)], { ...options, allowCodes: [1] });
|
|
40
|
+
if (found.code === 1) return { code: 1, stdout: '', ok: false };
|
|
41
|
+
}
|
|
42
|
+
// Native commands and the Compose provider must address the SAME local store.
|
|
43
|
+
const prefix = args[0] === 'compose' ? ['--remote', '--url', `unix://${b.socket}`] : ['--remote=false', '--root', b.graphRoot, '--runroot', b.runRoot, '--storage-driver', b.driver];
|
|
44
|
+
const result = await effects.run('podman', [...prefix, ...args],
|
|
45
|
+
{ ...options, env: { ...options.env, ...selected } });
|
|
46
|
+
if (args[0] === 'image' && args[1] === 'inspect' && !args.includes('--format') && result.code === 0) {
|
|
47
|
+
const images = JSON.parse(result.stdout);
|
|
48
|
+
for (const value of images) if (/^[a-f0-9]{64}$/.test(value.Id)) value.Id = `sha256:${value.Id}`;
|
|
49
|
+
return { ...result, stdout: JSON.stringify(images) };
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
export function namespaceDigest(info) {
|
|
54
|
+
const maps = info.host?.idMappings;
|
|
55
|
+
const normalized = ['uidmap', 'gidmap'].map(key => {
|
|
56
|
+
const rows = maps?.[key];
|
|
57
|
+
if (!Array.isArray(rows) || !rows.length) throw new Error('Podman user namespace mappings are unavailable');
|
|
58
|
+
return rows.map(row => {
|
|
59
|
+
const values = [row.container_id, row.host_id, row.size];
|
|
60
|
+
if (values.some(value => !Number.isInteger(value) || value < 0) || row.size < 1) throw new Error('Invalid Podman namespace mapping');
|
|
61
|
+
return values;
|
|
62
|
+
}).sort((a, b) => a[0] - b[0]);
|
|
63
|
+
});
|
|
64
|
+
return createHash('sha256').update(JSON.stringify(normalized)).digest('hex');
|
|
65
|
+
}
|
|
66
|
+
export async function bindPodman(effects, record) {
|
|
67
|
+
if (effects.platform.platform !== 'linux' || process.getuid?.() === 0) throw new Error('Podman server installation requires a non-root Linux user');
|
|
68
|
+
const env = effects.env ?? {};
|
|
69
|
+
const provider = record.containerBinding?.provider ?? env.PODMAN_COMPOSE_PROVIDER;
|
|
70
|
+
if (!path(provider)) throw new Error('Set PODMAN_COMPOSE_PROVIDER to an absolute standalone Docker Compose executable path');
|
|
71
|
+
// Reject ambient redirection before even probing an unbound store.
|
|
72
|
+
for (const key of ['CONTAINER_HOST', 'CONTAINER_CONNECTION', 'CONTAINERS_STORAGE_CONF', 'CONTAINERS_CONF', 'PODMAN_USERNS', 'STORAGE_DRIVER', 'STORAGE_OPTS', 'DOCKER_CONTEXT']) {
|
|
73
|
+
if (env[key]) throw new Error(`${key} conflicts with local rootless Podman`);
|
|
74
|
+
}
|
|
75
|
+
const info = JSON.parse((await effects.run('podman', ['--remote=false', 'info', '--format', 'json'])).stdout);
|
|
76
|
+
if (info.host?.security?.rootless !== true) throw new Error('Podman must report rootless=true');
|
|
77
|
+
const socket = record.containerBinding?.socket ?? env.OURS_PODMAN_SOCKET ?? info.host.remoteSocket?.path;
|
|
78
|
+
const observed = { version: 1, uid: process.getuid(), graphRoot: info.store?.graphRoot, runRoot: info.store?.runRoot, driver: info.store?.graphDriverName, socket, provider, namespaceDigest: namespaceDigest(info) };
|
|
79
|
+
validateContainerEngine({ ...record, containerBinding: observed });
|
|
80
|
+
if (record.containerBinding && Object.keys(observed).some(k => observed[k] !== record.containerBinding[k])) throw new Error('Podman backend differs from retained installation');
|
|
81
|
+
podmanEnvironment({ ...record, containerBinding: observed }, env, process.getuid());
|
|
82
|
+
const api = JSON.parse((await effects.run('podman', ['--remote', '--url', `unix://${socket}`, 'info', '--format', 'json'])).stdout);
|
|
83
|
+
if (api.host?.security?.rootless !== true || api.store?.graphRoot !== observed.graphRoot || api.store?.runRoot !== observed.runRoot || api.store?.graphDriverName !== observed.driver || namespaceDigest(api) !== observed.namespaceDigest) throw new Error('Podman socket does not address the selected rootless storage; enable the user podman.socket');
|
|
84
|
+
const version = (await effects.run(provider, ['version', '--short'])).stdout.trim();
|
|
85
|
+
const match = /^v?(\d+)\.(\d+)\./.exec(version);
|
|
86
|
+
if (!match || +match[1] < 2 || (+match[1] === 2 && +match[2] < 35)) throw new Error('Standalone Docker Compose 2.35+ is required');
|
|
87
|
+
record.containerBinding = observed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Resolve Compose rather than maintaining a second, drifting build definition. */
|
|
91
|
+
export function nativeBuildCommands(config, services, env = {}) {
|
|
92
|
+
const commands = [], seen = new Set();
|
|
93
|
+
for (const name of services) {
|
|
94
|
+
const service = config.services?.[name], build = service?.build;
|
|
95
|
+
if (!build || typeof build !== 'object' || !path(build.context) || !service.image) throw new Error(`Missing resolved build definition for ${name}`);
|
|
96
|
+
const supported = ['context', 'dockerfile', 'target', 'args', 'secrets', 'cache_from', 'cache_to', 'platforms', 'labels', 'no_cache', 'pull'];
|
|
97
|
+
if (Object.keys(build).some(k => !supported.includes(k))) throw new Error(`Unsupported Podman build option for ${name}`);
|
|
98
|
+
const args = ['build', '--format', 'docker', '--layers', '--file', resolve(build.context, build.dockerfile ?? 'Dockerfile'), '--tag', service.image];
|
|
99
|
+
if (build.target) args.push('--target', build.target);
|
|
100
|
+
const platforms = build.platforms ?? (service.platform ? [service.platform] : []);
|
|
101
|
+
if (platforms.length > 1) throw new Error('Multi-platform Podman builds are not supported');
|
|
102
|
+
if (platforms.length) args.push('--platform', platforms[0]);
|
|
103
|
+
for (const [key, value] of Object.entries(build.args ?? {})) {
|
|
104
|
+
if (value === null) throw new Error(`Unresolved build argument: ${key}`);
|
|
105
|
+
args.push('--build-arg', `${key}=${value}`);
|
|
106
|
+
}
|
|
107
|
+
for (const [key, value] of Object.entries(build.labels ?? {})) args.push('--label', `${key}=${value}`);
|
|
108
|
+
for (const key of ['cache_from', 'cache_to']) for (const value of build[key] ?? []) args.push('--' + key.replace('_', '-'), value);
|
|
109
|
+
if (build.no_cache) args.push('--no-cache');
|
|
110
|
+
if (build.pull) args.push('--pull=always');
|
|
111
|
+
for (const secret of build.secrets ?? []) {
|
|
112
|
+
const source = typeof secret === 'string' ? secret : secret.source;
|
|
113
|
+
const id = typeof secret === 'string' ? source : secret.target ?? source;
|
|
114
|
+
const definition = config.secrets?.[source];
|
|
115
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error('Invalid build secret ID');
|
|
116
|
+
if (definition?.environment) {
|
|
117
|
+
// Native local Podman supports type=env; values never enter argv/files.
|
|
118
|
+
if (env[definition.environment]) args.push('--secret', `id=${id},type=env,env=${definition.environment}`);
|
|
119
|
+
} else if (definition?.file && path(definition.file)) args.push('--secret', `id=${id},src=${definition.file}`);
|
|
120
|
+
else throw new Error(`Unsupported build secret: ${source}`);
|
|
121
|
+
}
|
|
122
|
+
args.push(build.context);
|
|
123
|
+
const key = JSON.stringify(args);
|
|
124
|
+
if (!seen.has(key)) { seen.add(key); commands.push(args); }
|
|
125
|
+
}
|
|
126
|
+
return commands;
|
|
127
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { redactDiagnostic, commandFailure, installerFailure } from '../assets/scripts/runtime/diagnostics.mjs';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { runContainer } from './container-engine.mjs';
|
|
1
2
|
import * as fs from 'node:fs';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
@@ -33,7 +34,7 @@ ENTRYPOINT ["node", "/opt/ours/docker/state-operation.mjs"]
|
|
|
33
34
|
`);
|
|
34
35
|
// Build maintenance first: both targets inherit the same retained runtime.
|
|
35
36
|
for (const target of ['maintenance', 'runtime']) {
|
|
36
|
-
await effects
|
|
37
|
+
await runContainer(effects, record, ['build', '--platform', 'linux/amd64', '--target', target,
|
|
37
38
|
'--tag', `${record.project}:${target}`, context]);
|
|
38
39
|
}
|
|
39
40
|
atomicWriteConfig(compose, fs.readFileSync(join(assets, 'docker-compose.yaml')));
|
|
@@ -82,7 +83,7 @@ export async function runDockerConversion(record, selected, operation, label, ef
|
|
|
82
83
|
const path = join(record.root, `.conversion-compose-${randomUUID()}.json`);
|
|
83
84
|
try {
|
|
84
85
|
fs.writeFileSync(path, JSON.stringify(definition), { mode: 0o600, flag: 'wx' });
|
|
85
|
-
const result = await effects
|
|
86
|
+
const result = await runContainer(effects, record, ['compose', '--file', path, '--project-name', record.project,
|
|
86
87
|
'run', '--rm', '--no-deps', '-T', 'state-operation', operation, ...(label ? [label] : [])]);
|
|
87
88
|
if (operation === 'cleanup') {
|
|
88
89
|
const summary = JSON.parse(result.stdout);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { runContainer } from './container-engine.mjs';
|
|
1
2
|
import * as fs from 'node:fs';
|
|
2
3
|
import { join, basename } from 'node:path';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
@@ -46,7 +47,7 @@ export async function convertDockerInstallation(record, operation, effects) {
|
|
|
46
47
|
await effects.serverLifecycle(record, 'start', running);
|
|
47
48
|
const cleanup = await effects.runDockerConversion(record, volumes, 'cleanup');
|
|
48
49
|
if (cleanup.emptyVolumes.length) {
|
|
49
|
-
await effects
|
|
50
|
+
await runContainer(effects, record, ['volume', 'rm', ...cleanup.emptyVolumes.map(alias => volumes.sources[alias])]);
|
|
50
51
|
}
|
|
51
52
|
try {
|
|
52
53
|
const stat = fs.lstatSync(source.configPath);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFileSync, lstatSync, realpathSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { atomicWriteConfig } from './config.mjs';
|
|
4
|
+
import { engineName, runContainer } from './container-engine.mjs';
|
|
5
|
+
|
|
6
|
+
const assets = new URL('../assets/', import.meta.url);
|
|
7
|
+
const files = ['scripts/runtime/recover.mjs', 'scripts/runtime/dependency-ready.mjs',
|
|
8
|
+
...['entrypoint.sh', 'start-telegram.sh', 'start-cowork.sh', 'start-messenger.sh'].map(f => `scripts/runtime/${f}`)];
|
|
9
|
+
const owned = (path, directory = false) => {
|
|
10
|
+
const s = lstatSync(path);
|
|
11
|
+
if (!(directory ? s.isDirectory() : s.isFile()) || s.uid !== process.getuid() || (!directory && s.nlink !== 1)
|
|
12
|
+
|| realpathSync(path) !== path || (s.mode & 0o7000)) throw new Error('Unsafe retained recovery delivery');
|
|
13
|
+
};
|
|
14
|
+
/** Refresh installer-owned runtime scripts without changing source selection or state. */
|
|
15
|
+
export function refreshDockerRecovery(record) {
|
|
16
|
+
for (const dir of [record.root, record.workDir, join(record.workDir, 'scripts'), join(record.workDir, 'scripts/runtime')]) owned(dir, true);
|
|
17
|
+
const marker = join(record.workDir, '.recovery-rebuild');
|
|
18
|
+
let changed = false;
|
|
19
|
+
const replace = (name, bytes) => {
|
|
20
|
+
const path = join(record.workDir, name);
|
|
21
|
+
if (existsSync(path)) { owned(path); if (readFileSync(path).equals(bytes)) return; }
|
|
22
|
+
// Persist before edits, so interruption cannot reuse an image lacking recovery.
|
|
23
|
+
atomicWriteConfig(marker, 'required\n'); changed = true;
|
|
24
|
+
atomicWriteConfig(path, bytes.toString());
|
|
25
|
+
};
|
|
26
|
+
for (const name of files) replace(name, readFileSync(new URL(name, assets)));
|
|
27
|
+
const dockerfile = join(record.workDir, 'Dockerfile'); owned(dockerfile);
|
|
28
|
+
const text = readFileSync(dockerfile, 'utf8');
|
|
29
|
+
const line = 'COPY --chmod=644 scripts/runtime/recover.mjs scripts/runtime/dependency-ready.mjs /opt/ours/docker/';
|
|
30
|
+
if (!text.includes('scripts/runtime/recover.mjs')) replace('Dockerfile', Buffer.from(text.replace('FROM node:24 AS maintenance', `${line}\nFROM node:24 AS maintenance`)));
|
|
31
|
+
return changed || existsSync(marker);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** An overlay also applies to retained Compose files, with one-shot jobs untouched. */
|
|
35
|
+
export function recoveryCompose(record) {
|
|
36
|
+
return { services: Object.fromEntries(record.services.map(name => [name, { restart: 'unless-stopped' }])) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Change only exact project-owned long-running containers; never start/recreate. */
|
|
40
|
+
export async function reconcileDockerRecovery(record, effects) {
|
|
41
|
+
if (engineName(record) !== 'docker') return;
|
|
42
|
+
const query = await runContainer(effects, record, ['ps', '-aq', '--no-trunc', '--filter', `label=com.docker.compose.project=${record.project}`]);
|
|
43
|
+
const ids = query.stdout.split(/\s+/).filter(Boolean);
|
|
44
|
+
if (!ids.length) return;
|
|
45
|
+
const inspected = JSON.parse((await runContainer(effects, record, ['inspect', ...ids])).stdout);
|
|
46
|
+
const selected = [];
|
|
47
|
+
for (const c of inspected) {
|
|
48
|
+
const labels = c.Config?.Labels;
|
|
49
|
+
if (labels?.['com.docker.compose.project'] !== record.project) throw new Error('Recovery container project ownership differs');
|
|
50
|
+
if (!record.services.includes(labels['com.docker.compose.service']) || labels['com.docker.compose.oneoff']?.toLowerCase() !== 'false') continue;
|
|
51
|
+
if (!ids.includes(c.Id)) throw new Error('Recovery container identity differs');
|
|
52
|
+
if (c.HostConfig?.RestartPolicy?.Name !== 'unless-stopped') selected.push(c.Id);
|
|
53
|
+
}
|
|
54
|
+
if (selected.length) await runContainer(effects, record, ['update', '--restart', 'unless-stopped', ...selected]);
|
|
55
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { engineName, runContainer } from './container-engine.mjs';
|
|
2
|
+
/** Qualify cached images before reuse; repair only the known root-owned 0600 policy. */
|
|
3
|
+
import { existsSync, readdirSync, lstatSync, readFileSync, writeFileSync, mkdtempSync, rmSync, realpathSync, openSync, closeSync, fstatSync, fchmodSync, constants } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
6
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
7
|
+
import { atomicWriteConfig } from './config.mjs';
|
|
8
|
+
|
|
9
|
+
const policyPath = '/opt/ours/sources.json';
|
|
10
|
+
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
11
|
+
const fail = message => { throw new Error(`Docker runtime verification refused: ${message}`); };
|
|
12
|
+
const imageId = value => /^sha256:[0-9a-f]{64}$/.test(value ?? '');
|
|
13
|
+
|
|
14
|
+
export function refreshDockerPolicyCopy(record) {
|
|
15
|
+
let rebuildScripts = false;
|
|
16
|
+
if (record.workDir !== join(record.root, 'runtime')) fail('Unsafe retained Dockerfile: runtime directory differs from installation record');
|
|
17
|
+
for (const directory of [record.root, record.workDir]) {
|
|
18
|
+
const stat = lstatSync(directory);
|
|
19
|
+
if (!stat.isDirectory() || realpathSync(directory) !== directory) fail('Unsafe retained Dockerfile: installation directory is linked or not canonical');
|
|
20
|
+
if (stat.uid !== process.getuid()) fail('Unsafe retained Dockerfile: installation directory belongs to another user');
|
|
21
|
+
if (stat.mode & 0o7077) fail('Unsafe retained Dockerfile: installation directory must be owner-private (0700)');
|
|
22
|
+
}
|
|
23
|
+
const path = join(record.workDir, 'Dockerfile');
|
|
24
|
+
let fd;
|
|
25
|
+
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
26
|
+
catch (error) { fail(`Unsafe retained Dockerfile: cannot open a regular unlinked file (${error.code})`); }
|
|
27
|
+
try {
|
|
28
|
+
const stat = fstatSync(fd);
|
|
29
|
+
if (!stat.isFile()) fail('Unsafe retained Dockerfile: expected a regular file');
|
|
30
|
+
if (stat.nlink !== 1) fail('Unsafe retained Dockerfile: hard links are not supported');
|
|
31
|
+
if (stat.uid !== process.getuid()) fail(`Unsafe retained Dockerfile: file owner ${stat.uid} differs from current user ${process.getuid()}`);
|
|
32
|
+
if (stat.mode & 0o7000) fail('Unsafe retained Dockerfile: special permission bits are not supported');
|
|
33
|
+
// Asset copies can retain 0664 under umask 002. Private canonical ancestors
|
|
34
|
+
// exclude other users; normalize this owned inode without following links.
|
|
35
|
+
fchmodSync(fd, 0o600);
|
|
36
|
+
const text = readFileSync(fd, 'utf8');
|
|
37
|
+
const current = lstatSync(path);
|
|
38
|
+
if (current.dev !== stat.dev || current.ino !== stat.ino || current.nlink !== 1) fail('Unsafe retained Dockerfile: file changed during normalization');
|
|
39
|
+
const oldLine = /^COPY sources\.json \/opt\/ours\/sources\.json$/m;
|
|
40
|
+
let updated = text.replace(oldLine, 'COPY --chmod=644 sources.json /opt/ours/sources.json');
|
|
41
|
+
// Only the shipped script COPY instructions are normalized. Existing image
|
|
42
|
+
// repair remains narrow; a private build context needs an actual rebuild.
|
|
43
|
+
const beforeScripts = updated;
|
|
44
|
+
const shipped = readFileSync(new URL('../assets/Dockerfile', import.meta.url), 'utf8');
|
|
45
|
+
const scriptCopies = new Map(shipped.split('\n').filter(line => line.startsWith('COPY --chmod=644 scripts/')).map(line => [line.replace(' --chmod=644', ''), line]));
|
|
46
|
+
updated = updated.split('\n').map(line => scriptCopies.get(line) || line).join('\n');
|
|
47
|
+
if (updated !== beforeScripts) {
|
|
48
|
+
for (const directory of ['scripts/maintenance', 'scripts/runtime']) {
|
|
49
|
+
const base = join(record.workDir, directory);
|
|
50
|
+
const dir = lstatSync(base);
|
|
51
|
+
if (!dir.isDirectory() || dir.uid !== process.getuid() || realpathSync(base) !== base) fail('Unsafe retained scripts directory');
|
|
52
|
+
for (const name of readdirSync(base)) {
|
|
53
|
+
if (!/\.(?:mjs|sh)$/.test(name)) continue;
|
|
54
|
+
const file = join(base, name), st = lstatSync(file);
|
|
55
|
+
if (!st.isFile() || st.nlink !== 1 || st.uid !== process.getuid() || realpathSync(file) !== file) fail('Unsafe retained script file');
|
|
56
|
+
if (!(st.mode & 0o004)) rebuildScripts = true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Store the need across an interrupted build. It is cleared only after the
|
|
61
|
+
// rebuilt image passes the same nonroot release/runtime qualification.
|
|
62
|
+
const marker = join(record.workDir, '.script-permissions-rebuild');
|
|
63
|
+
if (rebuildScripts) atomicWriteConfig(marker, 'required\n');
|
|
64
|
+
if (updated !== text) atomicWriteConfig(path, updated);
|
|
65
|
+
return rebuildScripts || existsSync(marker);
|
|
66
|
+
} finally { closeSync(fd); }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const probeScript = `
|
|
70
|
+
const fs = require('node:fs'), crypto = require('node:crypto');
|
|
71
|
+
(async () => {
|
|
72
|
+
const path = '${policyPath}', s = fs.lstatSync(path);
|
|
73
|
+
if (!s.isFile() || s.nlink !== 1 || s.uid !== 0 || s.gid !== 0 || ![0o600, 0o644].includes(s.mode & 0o7777)) throw new Error('Unsupported policy ownership/type/mode');
|
|
74
|
+
let bytes;
|
|
75
|
+
try { bytes = fs.readFileSync(path); }
|
|
76
|
+
catch (error) { if (process.getuid() !== 0 && error.code === 'EACCES') { process.exitCode = 74; return; } throw error; }
|
|
77
|
+
const graph = await import('/opt/ours/maintenance/release-graph.mjs');
|
|
78
|
+
graph.verifyRuntimeRelease('/opt/ours');
|
|
79
|
+
const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
80
|
+
const records = {};
|
|
81
|
+
for (const name of ['package.json', 'package-lock.json', 'dependency-tree.json', 'build-context.json']) {
|
|
82
|
+
const file = '/opt/ours/' + name, stat = fs.lstatSync(file);
|
|
83
|
+
if (!stat.isFile() || stat.nlink !== 1) throw new Error('Unsupported build record');
|
|
84
|
+
records[name] = hash(fs.readFileSync(file));
|
|
85
|
+
}
|
|
86
|
+
console.log(JSON.stringify({ uid: s.uid, gid: s.gid, regular: true, links: s.nlink, mode: s.mode & 0o7777, policyHash: hash(bytes), records }));
|
|
87
|
+
})().catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
export async function qualifyDockerRuntime(record, effects) {
|
|
91
|
+
if (!/^ours-[a-z0-9-]+$/.test(record.project ?? '')) fail('unexpected installation project');
|
|
92
|
+
const uid = record.uid ?? 1000, gid = record.gid ?? 1000;
|
|
93
|
+
if (![uid, gid].every(n => Number.isInteger(n) && n > 0)) fail('non-root runtime UID/GID required');
|
|
94
|
+
const tag = `${record.project}:runtime`;
|
|
95
|
+
const inspect = async target => {
|
|
96
|
+
const { stdout } = await runContainer(effects, record, ['image', 'inspect', target], { timeout: 60000 });
|
|
97
|
+
const [value] = JSON.parse(stdout);
|
|
98
|
+
if (!imageId(value?.Id) || !Array.isArray(value.RootFS?.Layers) || value.Config?.Labels?.['network.ours.build-context'] !== '1') fail('image lacks recognized build metadata');
|
|
99
|
+
return value;
|
|
100
|
+
};
|
|
101
|
+
const probe = async (id, user) => {
|
|
102
|
+
const name = `ours-runtime-probe-${randomUUID()}`;
|
|
103
|
+
try {
|
|
104
|
+
const result = await runContainer(effects, record, ['run', '--rm', '--name', name, '--read-only', '--network', 'none', '--cap-drop', 'ALL',
|
|
105
|
+
'--security-opt', 'no-new-privileges:true', '--pids-limit', '64', '--memory', '256m', '--user', user, '--entrypoint', 'node', id, '-e', probeScript],
|
|
106
|
+
{ allowCodes: [1, 74], timeout: 60000 });
|
|
107
|
+
return { code: result.code, report: result.code === 0 ? JSON.parse(result.stdout) : null };
|
|
108
|
+
} catch (error) {
|
|
109
|
+
// A killed Docker client can leave its own bounded probe container behind.
|
|
110
|
+
try { await runContainer(effects, record, ['rm', '-f', name], { allowCodes: [1], timeout: 10000 }); } catch {}
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const policy = readFileSync(record.sourcesPath), expectedHash = digest(policy);
|
|
115
|
+
const check = (report, mode, stage) => {
|
|
116
|
+
const expected = { mode, uid: 0, gid: 0, regular: true, links: 1, policyHash: expectedHash };
|
|
117
|
+
const mismatched = Object.keys(expected).filter(key => report?.[key] !== expected[key]);
|
|
118
|
+
if (mismatched.length) {
|
|
119
|
+
// Only fixed field names, a bounded numeric mode and a comparison boolean
|
|
120
|
+
// enter diagnostics. Never disclose policy content, hashes or probe output.
|
|
121
|
+
const observedMode = Number.isInteger(report?.mode) && report.mode >= 0 && report.mode <= 0o7777
|
|
122
|
+
? report.mode.toString(8).padStart(4, '0') : 'invalid';
|
|
123
|
+
fail(`image policy verification failed (stage=${stage}; engine=${engineName(record)}; mismatched=${mismatched.join(',')}; mode=${observedMode}; expectedMode=${mode.toString(8).padStart(4, '0')}; policyMatch=${report?.policyHash === expectedHash})`);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const original = await inspect(tag);
|
|
127
|
+
const ordinary = await probe(original.Id, `${uid}:${gid}`);
|
|
128
|
+
if (ordinary.code === 0) { check(ordinary.report, 0o644, 'ordinary'); return; }
|
|
129
|
+
if (ordinary.code !== 74) fail('cached image is unusable; automatic repair only supports the known source-policy permission defect');
|
|
130
|
+
const privileged = await probe(original.Id, '0:0');
|
|
131
|
+
if (privileged.code !== 0) fail('original release graph could not be verified');
|
|
132
|
+
check(privileged.report, 0o600, 'privileged');
|
|
133
|
+
effects.out?.('Repairing cached Docker image permissions; keeping the selected packages and stored data.');
|
|
134
|
+
const directory = mkdtempSync(join(record.root, '.image-permissions-'));
|
|
135
|
+
const name = `ours-permission-repair-${randomUUID()}`, baseTag = `${name}:base`, candidateTag = `${name}:candidate`;
|
|
136
|
+
try {
|
|
137
|
+
// Unique local alias transports the immutable ID into BuildKit. The resulting
|
|
138
|
+
// layer ancestry and complete execution config are checked before publication.
|
|
139
|
+
await runContainer(effects, record, ['image', 'tag', original.Id, baseTag]);
|
|
140
|
+
if ((await inspect(baseTag)).Id !== original.Id) fail('repair base image changed');
|
|
141
|
+
writeFileSync(join(directory, 'sources.json'), policy, { mode: 0o600, flag: 'wx' });
|
|
142
|
+
writeFileSync(join(directory, 'Dockerfile'), `FROM ${baseTag}\nCOPY --chmod=644 sources.json ${policyPath}\n`, { mode: 0o600, flag: 'wx' });
|
|
143
|
+
await runContainer(effects, record, ['build', '--network=none', '--pull=false', '--tag', candidateTag, directory], { stream: true, timeout: 120000 });
|
|
144
|
+
const candidate = await inspect(candidateTag);
|
|
145
|
+
if (!isDeepStrictEqual(candidate.Config, original.Config) || !isDeepStrictEqual(candidate.RootFS.Layers.slice(0, original.RootFS.Layers.length), original.RootFS.Layers)
|
|
146
|
+
|| candidate.RootFS.Layers.length !== original.RootFS.Layers.length + 1) fail('repair changed image execution settings or ancestry');
|
|
147
|
+
const verified = await probe(candidate.Id, `${uid}:${gid}`);
|
|
148
|
+
if (verified.code !== 0) fail('repaired image did not pass runtime verification');
|
|
149
|
+
check(verified.report, 0o644, 'repaired');
|
|
150
|
+
if (!isDeepStrictEqual(verified.report.records, privileged.report.records)) fail('repair changed build provenance');
|
|
151
|
+
if ((await inspect(tag)).Id !== original.Id) fail('selected image changed during repair; retry setup');
|
|
152
|
+
await runContainer(effects, record, ['image', 'tag', candidate.Id, tag]);
|
|
153
|
+
effects.out?.('Cached Docker image repaired and verified; continuing setup.');
|
|
154
|
+
} finally {
|
|
155
|
+
for (const temporary of [candidateTag, baseTag]) {
|
|
156
|
+
try { await runContainer(effects, record, ['image', 'rm', temporary], { allowCodes: [1], timeout: 10000 }); } catch {}
|
|
157
|
+
}
|
|
158
|
+
rmSync(directory, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
}
|