#!/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')}`);