Files
vshell/tools/build-image.mjs
mward4 a6f8e67976 VShell 5.0.1-3985 container image
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.
2026-10-05 12:15:20 -04:00

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