Build the image from the vendor vshell-https .deb on ubuntu:24.04 (required: GLIBC_2.38 / GLIBCXX_3.4.32). Contains the Dockerfile, the entrypoint that starts and supervises the three VShell daemons without systemd, the no-container-runtime build/validate/push tooling used to publish the image, and the vendored .deb (sha256 pinned). Published as gitea.milesward.dev/mward4/vshell:5.0.1-3985 and :latest. Manifest digest sha256:8b0ff00be981fa05f1e4a4e4ff5307b6c7e5e61b487d4fbe7e02fefb2a04659f.
324 lines
13 KiB
JavaScript
Executable File
324 lines
13 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
// Assemble a runnable container image without a container runtime.
|
|
//
|
|
// This environment has no docker/podman/buildah, so the image is built the long
|
|
// way: the validated rootfs (a real `dpkg -i` install of the vendor .deb on top
|
|
// of ubuntu:24.04 - see tools/validate.sh) is diffed against the pristine base
|
|
// layer, the diff becomes one new layer, and a Docker schema-2 manifest that
|
|
// reuses the official ubuntu:24.04 layer is written out. tools/push-image.mjs
|
|
// uploads the result to a registry.
|
|
//
|
|
// Usage:
|
|
// node tools/build-image.mjs --base /tmp/vshell-base --pristine /tmp/vshell-pristine \
|
|
// --rootfs /tmp/vshell-rootfs --out dist --tag vshell:5.0.1-3985,vshell:latest
|
|
import { createHash } from 'node:crypto';
|
|
import { createReadStream, createWriteStream } from 'node:fs';
|
|
import { copyFile, mkdir, open, readdir, readFile, readlink, lstat, writeFile } from 'node:fs/promises';
|
|
import { createGzip } from 'node:zlib';
|
|
import { pipeline } from 'node:stream/promises';
|
|
import path from 'node:path';
|
|
|
|
const VERSION = '5.0.1-3985';
|
|
|
|
function parseArgs(argv) {
|
|
const out = {};
|
|
for (let i = 0; i < argv.length; i += 2) out[argv[i].replace(/^--/, '')] = argv[i + 1];
|
|
return out;
|
|
}
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const BASE = args.base || '/tmp/vshell-base';
|
|
const PRISTINE = args.pristine || '/tmp/vshell-pristine';
|
|
const ROOTFS = args.rootfs || '/tmp/vshell-rootfs';
|
|
const OUT = path.resolve(args.out || 'dist');
|
|
const TAGS = (args.tag || `vshell:${VERSION}`).split(',');
|
|
|
|
const sha256 = (buf) => 'sha256:' + createHash('sha256').update(buf).digest('hex');
|
|
const hex = (digest) => digest.replace(/^sha256:/, '');
|
|
|
|
// ---------------------------------------------------------------- filesystem
|
|
|
|
async function walk(root) {
|
|
const entries = new Map();
|
|
async function visit(rel) {
|
|
const abs = rel === '' ? root : path.join(root, rel);
|
|
for (const d of await readdir(abs, { withFileTypes: true })) {
|
|
const childRel = rel === '' ? d.name : `${rel}/${d.name}`;
|
|
const childAbs = path.join(root, childRel);
|
|
const st = await lstat(childAbs);
|
|
const e = {
|
|
path: childRel,
|
|
type: st.isDirectory() ? 'd' : st.isSymbolicLink() ? 'l' : 'f',
|
|
mode: st.mode & 0o7777,
|
|
uid: 0, // rootfs is extracted without --same-owner; every file maps to container uid 0
|
|
gid: 0,
|
|
mtime: Math.floor(st.mtimeMs / 1000),
|
|
size: st.size,
|
|
nlink: st.nlink,
|
|
ino: st.ino,
|
|
dev: st.dev,
|
|
};
|
|
if (e.type === 'l') e.linkname = await readlink(childAbs);
|
|
if (e.type === 'f') e.digest = sha256(await readFile(childAbs));
|
|
entries.set(childRel, e);
|
|
if (e.type === 'd') await visit(childRel);
|
|
}
|
|
}
|
|
await visit('');
|
|
return entries;
|
|
}
|
|
|
|
function same(a, b) {
|
|
if (a.type !== b.type || a.mode !== b.mode || a.uid !== b.uid || a.gid !== b.gid) return false;
|
|
if (a.type === 'l') return a.linkname === b.linkname;
|
|
if (a.type === 'f') return a.size === b.size && a.digest === b.digest;
|
|
return true;
|
|
}
|
|
|
|
// ------------------------------------------------------------------ tar (ustar)
|
|
|
|
const BLOCK = 512;
|
|
|
|
function octal(buf, off, len, value) {
|
|
buf.write(value.toString(8).padStart(len - 1, '0'), off, len - 1, 'ascii');
|
|
buf[off + len - 1] = 0;
|
|
}
|
|
|
|
// ustar splits long paths into name(100) + prefix(155); returns null when the
|
|
// path does not fit, in which case the caller emits a GNU long-name record.
|
|
function splitName(name) {
|
|
if (Buffer.byteLength(name) <= 100) return { name, prefix: '' };
|
|
for (let i = name.length - 1; i > 0; i--) {
|
|
if (name[i] !== '/') continue;
|
|
const rest = name.slice(i + 1);
|
|
const prefix = name.slice(0, i);
|
|
if (Buffer.byteLength(rest) <= 100 && Buffer.byteLength(prefix) <= 155) return { name: rest, prefix };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function header({ name, mode, size, mtime, typeflag, linkname = '' }) {
|
|
const buf = Buffer.alloc(BLOCK);
|
|
const split = splitName(name);
|
|
const nm = typeflag === 'L' ? name.slice(0, 100) : split ? split.name : name.slice(-100);
|
|
buf.write(nm, 0, 100, 'utf8');
|
|
octal(buf, 100, 8, mode & 0o7777);
|
|
octal(buf, 108, 8, 0);
|
|
octal(buf, 116, 8, 0);
|
|
octal(buf, 124, 12, size);
|
|
octal(buf, 136, 12, mtime);
|
|
buf.write(' ', 148, 8, 'ascii');
|
|
buf.write(typeflag, 156, 1, 'ascii');
|
|
buf.write(linkname, 157, 100, 'utf8');
|
|
buf.write('ustar\0', 257, 6, 'ascii');
|
|
buf.write('00', 263, 2, 'ascii');
|
|
buf.write('root', 265, 32, 'ascii');
|
|
buf.write('root', 297, 32, 'ascii');
|
|
octal(buf, 329, 8, 0);
|
|
octal(buf, 337, 8, 0);
|
|
if (split) buf.write(split.prefix, 345, 155, 'utf8');
|
|
let sum = 0;
|
|
for (const b of buf) sum += b;
|
|
buf.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 8, 'ascii');
|
|
return buf;
|
|
}
|
|
|
|
async function writeTar(root, entries, tarPath) {
|
|
const hash = createHash('sha256');
|
|
const fd = await open(tarPath, 'w');
|
|
const write = async (buf) => { hash.update(buf); await fd.write(buf); };
|
|
const pad = (size) => (BLOCK - (size % BLOCK)) % BLOCK;
|
|
const hardlinks = new Map();
|
|
|
|
for (const e of entries) {
|
|
const abs = path.join(root, e.path);
|
|
let name = e.type === 'd' && !e.path.endsWith('/') ? e.path + '/' : e.path;
|
|
|
|
// Overlay whiteout: a zero-length marker entry, never present on disk.
|
|
if (e.whiteout) {
|
|
await write(header({ name: e.path, mode: 0o644, size: 0, mtime: e.mtime, typeflag: '0' }));
|
|
continue;
|
|
}
|
|
|
|
if (Buffer.byteLength(name) > 100 && !splitName(name)) {
|
|
const long = Buffer.from(name + '\0', 'utf8');
|
|
await write(header({ name: '././@LongLink', mode: 0o644, size: long.length, mtime: e.mtime, typeflag: 'L' }));
|
|
await write(long);
|
|
if (pad(long.length)) await write(Buffer.alloc(pad(long.length)));
|
|
}
|
|
|
|
if (e.type === 'f' && e.nlink > 1) {
|
|
const key = `${e.dev}:${e.ino}`;
|
|
if (hardlinks.has(key)) {
|
|
await write(header({ name, mode: e.mode, size: 0, mtime: e.mtime, typeflag: '1', linkname: hardlinks.get(key) }));
|
|
continue;
|
|
}
|
|
hardlinks.set(key, name);
|
|
}
|
|
|
|
const typeflag = e.type === 'd' ? '5' : e.type === 'l' ? '2' : '0';
|
|
const size = e.type === 'f' ? e.size : 0;
|
|
await write(header({ name, mode: e.mode, size, mtime: e.mtime, typeflag, linkname: e.linkname || '' }));
|
|
if (e.type !== 'f') continue;
|
|
|
|
let written = 0;
|
|
for await (const chunk of createReadStream(abs)) {
|
|
await write(chunk);
|
|
written += chunk.length;
|
|
}
|
|
if (written !== size) throw new Error(`size changed while packing ${abs}: ${written} != ${size}`);
|
|
if (pad(size)) await write(Buffer.alloc(pad(size)));
|
|
}
|
|
|
|
await write(Buffer.alloc(BLOCK * 2));
|
|
await fd.close();
|
|
return 'sha256:' + hash.digest('hex');
|
|
}
|
|
|
|
// --------------------------------------------------------------- layer diff
|
|
|
|
async function buildLayer() {
|
|
console.log(`walking pristine base ${PRISTINE}`);
|
|
const base = await walk(PRISTINE);
|
|
console.log(`walking installed rootfs ${ROOTFS}`);
|
|
const now = await walk(ROOTFS);
|
|
console.log(` ${base.size} base entries, ${now.size} installed entries`);
|
|
|
|
const changed = [];
|
|
const deleted = [];
|
|
for (const [p, e] of now) {
|
|
const b = base.get(p);
|
|
if (!b || !same(b, e)) changed.push(e);
|
|
}
|
|
for (const p of base.keys()) if (!now.has(p)) deleted.push(p);
|
|
|
|
// Include the ancestors of new entries so their ownership/mode are explicit.
|
|
const need = new Map();
|
|
for (const e of changed) {
|
|
need.set(e.path, e);
|
|
let parts = e.path.split('/');
|
|
while (parts.length > 1) {
|
|
parts = parts.slice(0, -1);
|
|
const dir = parts.join('/');
|
|
if (!base.has(dir) && now.has(dir)) need.set(dir, now.get(dir));
|
|
}
|
|
}
|
|
const entries = [...need.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
|
|
const whiteouts = deleted.map((p) => {
|
|
const parent = path.posix.dirname(p);
|
|
return {
|
|
path: `${parent === '.' ? '' : parent + '/'}.wh.${path.posix.basename(p)}`,
|
|
type: 'f', mode: 0o644, uid: 0, gid: 0, mtime: 0, size: 0, nlink: 1, whiteout: true,
|
|
};
|
|
});
|
|
|
|
const files = changed.filter((e) => e.type === 'f');
|
|
console.log(`layer: ${entries.length} entries (${files.length} files), ${deleted.length} deletions, ` +
|
|
`${(files.reduce((n, e) => n + e.size, 0) / 1048576).toFixed(1)} MiB uncompressed`);
|
|
return { entries, whiteouts };
|
|
}
|
|
|
|
// --------------------------------------------------------------------- main
|
|
|
|
const baseManifest = JSON.parse(await readFile(path.join(BASE, 'manifest.json'), 'utf8'));
|
|
// Registries verify blob digests, so the base config must be pushed byte-for-byte.
|
|
const baseConfigRaw = await readFile(path.join(BASE, 'config.raw.json'));
|
|
if (sha256(baseConfigRaw) !== baseManifest.config.digest) throw new Error('base config blob does not match its manifest digest');
|
|
const baseConfig = JSON.parse(baseConfigRaw.toString('utf8'));
|
|
const { entries, whiteouts } = await buildLayer();
|
|
|
|
await mkdir(OUT, { recursive: true });
|
|
const layerTar = path.join(OUT, 'layer-vshell.tar');
|
|
const layerGz = path.join(OUT, 'layer-vshell.tar.gz');
|
|
const diffId = await writeTar(ROOTFS, [...entries, ...whiteouts], layerTar);
|
|
await pipeline(createReadStream(layerTar), createGzip({ level: 9 }), createWriteStream(layerGz));
|
|
const layerBuf = await readFile(layerGz);
|
|
const layerDigest = sha256(layerBuf);
|
|
console.log(`layer diff_id ${diffId}`);
|
|
console.log(`layer digest ${layerDigest} (${(layerBuf.length / 1048576).toFixed(1)} MiB compressed)`);
|
|
|
|
const created = new Date().toISOString();
|
|
const config = structuredClone(baseConfig);
|
|
config.created = created;
|
|
config.author = 'vshell container build';
|
|
config.config = {
|
|
...config.config,
|
|
Env: ['PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'],
|
|
Entrypoint: ['/usr/local/bin/vshell-entrypoint'],
|
|
Cmd: null,
|
|
WorkingDir: '/',
|
|
ExposedPorts: { '22/tcp': {}, '21/tcp': {}, '990/tcp': {}, '80/tcp': {}, '443/tcp': {} },
|
|
Labels: {
|
|
'org.opencontainers.image.title': 'VShell',
|
|
'org.opencontainers.image.description':
|
|
'VanDyke Software VShell for UNIX 5.0.1 - SSH2, SFTP, FTPS and HTTPS server (vendor .deb on ubuntu:24.04)',
|
|
'org.opencontainers.image.version': VERSION,
|
|
'org.opencontainers.image.vendor': 'VanDyke Software, Inc.',
|
|
},
|
|
};
|
|
config.history = [
|
|
...(config.history || []),
|
|
{
|
|
created,
|
|
created_by: `dpkg -i vshell-https_${VERSION}_amd64.deb && install docker/entrypoint.sh`,
|
|
comment: 'VShell 5.0.1-3985 installed on ubuntu:24.04 with openssl',
|
|
},
|
|
];
|
|
config.rootfs = { type: 'layers', diff_ids: [...baseConfig.rootfs.diff_ids, diffId] };
|
|
|
|
const configBuf = Buffer.from(JSON.stringify(config));
|
|
const configDigest = sha256(configBuf);
|
|
await writeFile(path.join(OUT, 'config.json'), configBuf);
|
|
|
|
const baseLayer = baseManifest.layers[0];
|
|
const manifest = {
|
|
schemaVersion: 2,
|
|
mediaType: 'application/vnd.docker.distribution.manifest.v2+json',
|
|
config: { mediaType: 'application/vnd.docker.container.image.v1+json', size: configBuf.length, digest: configDigest },
|
|
layers: [
|
|
{ mediaType: baseLayer.mediaType, size: baseLayer.size, digest: baseLayer.digest },
|
|
{ mediaType: 'application/vnd.docker.image.rootfs.diff.tar.gzip', size: layerBuf.length, digest: layerDigest },
|
|
],
|
|
};
|
|
const manifestBuf = Buffer.from(JSON.stringify(manifest));
|
|
const manifestDigest = sha256(manifestBuf);
|
|
console.log(`config digest ${configDigest}`);
|
|
console.log(`manifest digest ${manifestDigest}`);
|
|
|
|
// OCI layout, so the image can be inspected/mirrored with standard tooling.
|
|
const blobsDir = path.join(OUT, 'oci', 'blobs', 'sha256');
|
|
await mkdir(blobsDir, { recursive: true });
|
|
await writeFile(path.join(blobsDir, hex(configDigest)), configBuf);
|
|
await writeFile(path.join(blobsDir, hex(manifestDigest)), manifestBuf);
|
|
await writeFile(path.join(blobsDir, hex(layerDigest)), layerBuf);
|
|
await copyFile(path.join(BASE, 'layer-00.tar'), path.join(blobsDir, hex(baseLayer.digest)));
|
|
await copyFile(path.join(BASE, 'config.raw.json'), path.join(blobsDir, hex(baseManifest.config.digest)));
|
|
await writeFile(path.join(OUT, 'oci', 'oci-layout'), JSON.stringify({ imageLayoutVersion: '1.0.0' }));
|
|
await writeFile(
|
|
path.join(OUT, 'oci', 'index.json'),
|
|
JSON.stringify({
|
|
schemaVersion: 2,
|
|
mediaType: 'application/vnd.oci.image.index.v1+json',
|
|
manifests: [{
|
|
mediaType: manifest.mediaType,
|
|
digest: manifestDigest,
|
|
size: manifestBuf.length,
|
|
annotations: { 'org.opencontainers.image.ref.name': TAGS[0] },
|
|
platform: { architecture: 'amd64', os: 'linux' },
|
|
}],
|
|
}, null, 2),
|
|
);
|
|
|
|
// Write the manifest with the exact bytes that were hashed: registries digest
|
|
// what they receive, so pretty-printing here would break digest verification.
|
|
await writeFile(path.join(OUT, 'manifest.json'), manifestBuf);
|
|
await writeFile(path.join(OUT, 'manifest.pretty.json'), JSON.stringify(manifest, null, 2));
|
|
await writeFile(path.join(OUT, 'image-summary.json'), JSON.stringify({
|
|
tags: TAGS,
|
|
base: { source: 'ubuntu:24.04', digest: baseLayer.digest, size: baseLayer.size, mediaType: baseLayer.mediaType, blob: path.join(BASE, 'layer-00.tar'), configDigest: baseManifest.config.digest, configBlob: path.join(BASE, 'config.raw.json') },
|
|
layer: { digest: layerDigest, size: layerBuf.length, diffId, blob: layerGz },
|
|
config: { digest: configDigest, size: configBuf.length, blob: path.join(OUT, 'config.json') },
|
|
manifest: { digest: manifestDigest, size: manifestBuf.length, blob: path.join(OUT, 'manifest.json') },
|
|
}, null, 2));
|
|
console.log(`wrote ${path.join(OUT, 'image-summary.json')}`);
|