@ours.network/install 1.2.1-nightly.3 → 1.2.1-nightly.31
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 +353 -9
- package/ROOTLESS_PODMAN_VALIDATION.md +210 -0
- package/assets/Dockerfile +17 -13
- package/assets/Dockerfile.gateway +5 -0
- package/assets/docker-compose.yaml +4 -1
- package/assets/fleet-settings.example.json +48 -0
- package/assets/gateway-entrypoint.sh +37 -0
- package/assets/release-lock.json +1167 -4499
- 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 +4 -2
- package/assets/scripts/runtime/client-setup.mjs +80 -6
- package/assets/scripts/runtime/diagnostics.mjs +72 -0
- package/assets/scripts/runtime/entrypoint.sh +1 -1
- package/assets/scripts/runtime/health-messenger.mjs +7 -6
- package/assets/scripts/runtime/notifications-gateway.mjs +82 -0
- package/assets/scripts/runtime/start-messenger.sh +25 -4
- package/assets/scripts/runtime/supervise.mjs +35 -0
- package/assets/sources.json +54 -28
- package/install.mjs +7 -2
- 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-runtime-repair.mjs +160 -0
- package/lib/effects.mjs +298 -132
- package/lib/gateway-transition.mjs +87 -0
- package/lib/gateway.mjs +158 -0
- package/lib/legacy-migration.mjs +13 -2
- package/lib/orchestrate.mjs +135 -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 +88 -27
- package/lib/setup.mjs +35 -18
- package/lib/target.mjs +24 -8
- package/lib/uninstall.mjs +2 -2
- package/lib/usage.mjs +19 -6
- package/lib/workspace-setup.mjs +83 -0
- package/package.json +4 -2
|
@@ -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
|
+
}
|