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.
This commit is contained in:
mward4 committed 2026-10-05 12:15:20 -04:00
commit a6f8e67976
14 files changed
+867

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Build outputs (rebuilt by tools/build-image.mjs; large)
dist/layer-vshell.tar
dist/layer-vshell.tar.gz
dist/manifest.pretty.json
dist/oci/
+33
View File
@@ -0,0 +1,33 @@
# VShell for UNIX container image (SSH2 / SFTP / FTPS / HTTPS).
#
# The base must be Ubuntu 24.04: the vendor binaries link against
# GLIBC_2.38 and GLIBCXX_3.4.32, so the Debian 12 / Ubuntu 22.04 toolchains are
# too old (see "System Requirements" in the vendor readme.txt).
FROM ubuntu:24.04
# Every shared library the VShell binaries need is already in ubuntu:24.04
# (libpam0g, libpam-modules, libpam-runtime, libuuid1, zlib1g, libstdc++6,
# libaudit1, libcap-ng0). openssl is added because VShell's HTTPS and implicit
# FTPS listeners require a PKCS#12 certificate and the entrypoint generates a
# self-signed one when none is supplied.
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*
ARG DEB=vshell-https-5.0.1-3985.ubuntu24-64.x86_64.deb
COPY vendor/${DEB} /tmp/vshell.deb
# The vendor postinst calls systemctl; there is no systemd in this image, so
# stub the command out for the duration of the dpkg install.
RUN set -eux; \
printf '#!/bin/sh\necho "[systemctl stub] $*"\n' > /usr/local/bin/systemctl; \
chmod 0755 /usr/local/bin/systemctl; \
PATH=/usr/local/bin:$PATH dpkg -i /tmp/vshell.deb; \
rm -f /usr/local/bin/systemctl /tmp/vshell.deb
COPY docker/entrypoint.sh /usr/local/bin/vshell-entrypoint
RUN chmod 0755 /usr/local/bin/vshell-entrypoint
EXPOSE 22 21 990 80 443
ENTRYPOINT ["/usr/local/bin/vshell-entrypoint"]
+105
View File
@@ -0,0 +1,105 @@
# VShell container image
Container image for **VanDyke Software VShell for UNIX 5.0.1 (build 3985)** — the
SSH2/SFTP, FTPS and HTTPS file-transfer server — built from the vendor's
`vshell-https-5.0.1-3985.ubuntu24-64.x86_64.deb`.
Image: `gitea.milesward.dev/mward4/vshell:5.0.1-3985` (also tagged `latest`).
Manifest digest: `sha256:8b0ff00be981fa05f1e4a4e4ff5307b6c7e5e61b487d4fbe7e02fefb2a04659f`.
## What is in the image
| Layer | Contents |
|---|---|
| `ubuntu:24.04` | Base OS. Required: the vendor binaries link `GLIBC_2.38` and `GLIBCXX_3.4.32`, so Ubuntu 22.04 / Debian 12 are too old. |
| `layer-vshell.tar.gz` | `dpkg -i` of the vendor `.deb`, plus `openssl` (for the runtime self-signed PKCS#12 certificate) and the container entrypoint. |
Runtime dependencies are the ones the vendor binaries actually link against:
`libpam0g`, `libpam-modules`, `libpam-runtime` (password/keyboard-interactive
auth), `libuuid1`, `zlib1g`, `libstdc++6`, `libaudit1`, `libcap-ng0`. All are
already part of `ubuntu:24.04`; `openssl` is the only added package.
Systemd units shipped by the vendor are **not** used — there is no init system in
the image. `docker/entrypoint.sh` starts the three daemons and supervises them.
## Run
```sh
docker run -d --name vshell \
-p 22:22 -p 21:21 -p 990:990 -p 80:80 -p 443:443 \
-v /srv/vshell/vshelld_config:/usr/local/etc/vshelld_config \
-v /srv/vshell/vshell-https.p12:/usr/local/etc/vshell-https.p12 \
-v /srv/vshell/vshelld_hostkey:/usr/local/etc/vshelld_hostkey \
gitea.milesward.dev/mward4/vshell:5.0.1-3985
```
On first start the entrypoint seeds `vshelld_config` and `vshelld_primes.txt`
from the vendor defaults, generates a self-signed PKCS#12 certificate when
`/usr/local/etc/vshell-https.p12` is absent, and appends the listen addresses
below unless the mounted configuration already defines `HTTPSListenAddresses`.
| Port | Service | Env override |
|---|---|---|
| 22/tcp | SSH2 / SFTP (`vshelld`) | `VSHELL_SSH_ADDR` |
| 21/tcp | explicit FTPS (`vshell-ftpsd`) | `VSHELL_FTPS_EXPLICIT_ADDR` |
| 990/tcp | implicit FTPS (`vshell-ftpsd`) | `VSHELL_FTPS_IMPLICIT_ADDR` |
| 80/tcp | HTTP, redirects to HTTPS (`vshell-httpsd`) | `VSHELL_HTTP_ADDR` |
| 443/tcp | HTTPS file transfer (`vshell-httpsd`) | `VSHELL_HTTPS_ADDR` |
Other env vars: `VSHELL_CONFIG` (default `/usr/local/etc/vshelld_config`) and
`VSHELL_TLS_P12` (default `/usr/local/etc/vshell-https.p12`).
Mount individual files rather than a whole volume at `/usr/local/etc`: that
directory also holds the vendor defaults, the Diffie-Hellman primes file and the
`HttpsFiles/` web assets, which an empty volume would hide. Persist
`vshelld_config` and `vshelld_hostkey` to keep the server identity stable across
restarts.
VShell requires a license; the image starts on the shipped 60-day evaluation
license (valid until 2026-12-04). Check with `vshelld -license`, install a
license file and point `LicenseFilePath` at it for production.
## Build
Canonical build (needs Docker or Podman):
```sh
docker build -t vshell:5.0.1-3985 .
```
This repository also builds without a container runtime — the path used to
produce the published image — by installing the `.deb` into an extracted
`ubuntu:24.04` rootfs and turning the diff into an image layer:
```sh
node tools/fetch-base.mjs # ubuntu:24.04 -> /tmp/vshell-base
mkdir -p /tmp/vshell-rootfs && tar -xzf /tmp/vshell-base/layer-00.tar -C /tmp/vshell-rootfs --numeric-owner
docker/install-rootfs.sh /tmp/vshell-rootfs # dpkg -i + openssl + entrypoint (see script)
tools/validate.sh /tmp/vshell-rootfs # binaries, config, all five listeners
mkdir -p /tmp/vshell-pristine && tar -xzf /tmp/vshell-base/layer-00.tar -C /tmp/vshell-pristine --numeric-owner
node tools/build-image.mjs --rootfs /tmp/vshell-rootfs --pristine /tmp/vshell-pristine \
--base /tmp/vshell-base --out dist --tag vshell:5.0.1-3985,vshell:latest
GITEA_TOKEN=... node tools/push-image.mjs --registry https://gitea.milesward.dev \
--repo mward4/vshell --user mward4 --summary dist/image-summary.json
```
`tools/build-image.mjs` writes `dist/layer-vshell.tar.gz`, `dist/config.json`,
`dist/manifest.json`, `dist/image-summary.json` and an OCI layout under
`dist/oci/`. `tools/push-image.mjs` uploads the referenced blobs (including the
official `ubuntu:24.04` layer) and the schema-2 manifest, then reads the manifest
back and compares digests.
## Validation
`tools/validate.sh <rootfs>` starts the image's own entrypoint inside a
user-namespace chroot on high ports and probes every listener from outside:
SSH banner, FTPS banner, HTTPS/TLS handshake, implicit FTPS handshake and the
HTTP→HTTPS redirect, after checking `-version`, `-test_config` and `-license`.
## Licensing
VShell is commercial, closed-source software distributed under the VanDyke
Software EULA (see `/usr/share/doc/vshell-5.0.1/vshell_eula.pdf` in the image).
The vendor `.deb` is vendored here only to make the image build reproducible and
the Gitea repository/package is kept **private**. Do not publish the image or the
`.deb` publicly.
+1
View File
@@ -0,0 +1 @@
{"architecture":"amd64","config":{"Hostname":"","Domainname":"","User":"","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":null,"Image":"sha256:a86351613aff94739ba02421a1565b116e182d2d6b74fac45e8b67a0ec5b4ddb","Volumes":null,"WorkingDir":"/","Entrypoint":["/usr/local/bin/vshell-entrypoint"],"OnBuild":null,"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":"5.0.1-3985","org.opencontainers.image.vendor":"VanDyke Software, Inc."},"ExposedPorts":{"22/tcp":{},"21/tcp":{},"990/tcp":{},"80/tcp":{},"443/tcp":{}}},"container":"b01d182f1bc01df3fed04057e99dff927174ba8f542beb9852e1772cc9afe3e8","container_config":{"Hostname":"b01d182f1bc0","Domainname":"","User":"","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/bin/sh","-c","#(nop) ","CMD [\"/bin/bash\"]"],"Image":"sha256:a86351613aff94739ba02421a1565b116e182d2d6b74fac45e8b67a0ec5b4ddb","Volumes":null,"WorkingDir":"","Entrypoint":null,"OnBuild":null,"Labels":{"org.opencontainers.image.version":"24.04"}},"created":"2026-10-05T16:13:48.002Z","docker_version":"26.1.3","history":[{"created":"2026-09-18T21:20:35.594318434Z","created_by":"/bin/sh -c #(nop) ARG RELEASE","empty_layer":true},{"created":"2026-09-18T21:20:35.618842266Z","created_by":"/bin/sh -c #(nop) ARG LAUNCHPAD_BUILD_ARCH","empty_layer":true},{"created":"2026-09-18T21:20:35.647177412Z","created_by":"/bin/sh -c #(nop) LABEL org.opencontainers.image.version=24.04","empty_layer":true},{"created":"2026-09-18T21:20:37.722192569Z","created_by":"/bin/sh -c #(nop) ADD file:6c214fc3c3c22122c7eb68f7fb32783df308e60b1eaeb9baf7e4d0cc961d448d in / "},{"created":"2026-09-18T21:20:38.103397332Z","created_by":"/bin/sh -c #(nop) CMD [\"/bin/bash\"]","empty_layer":true},{"created":"2026-10-05T16:13:48.002Z","created_by":"dpkg -i vshell-https_5.0.1-3985_amd64.deb && install docker/entrypoint.sh","comment":"VShell 5.0.1-3985 installed on ubuntu:24.04 with openssl"}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:d81fb2a7c6ee3be05fdefaa7fd223be219c1ab172083ec1b994c3ecda33ac0a8","sha256:7a826cd4fca80f509dd9f8dd59467b576eab0721e8cb524b90387ddeee75c0c3"]},"author":"vshell container build"}
+31
View File
@@ -0,0 +1,31 @@
{
"tags": [
"vshell:5.0.1-3985",
"vshell:latest"
],
"base": {
"source": "ubuntu:24.04",
"digest": "sha256:a60ef8ec184edb5ed91f5983f00261359fb2fe20f603e8d488b9477d32fcad70",
"size": 29764422,
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"blob": "/tmp/vshell-base/layer-00.tar",
"configDigest": "sha256:69f919497cada9a8611f3de61df902d9f94bf20f84552ef5bd93125c38608ef9",
"configBlob": "/tmp/vshell-base/config.raw.json"
},
"layer": {
"digest": "sha256:7e7fd717d7775db6e6eb2e3171d45ae8b748ecbff4ce10aa263ea08199e8284f",
"size": 25222922,
"diffId": "sha256:7a826cd4fca80f509dd9f8dd59467b576eab0721e8cb524b90387ddeee75c0c3",
"blob": "/workspace/vshell/dist/layer-vshell.tar.gz"
},
"config": {
"digest": "sha256:552ac268102d52c36ef9d74f11946f0b7464c6e49c545dbcba3acc2c3c3a1c1b",
"size": 2588,
"blob": "/workspace/vshell/dist/config.json"
},
"manifest": {
"digest": "sha256:8b0ff00be981fa05f1e4a4e4ff5307b6c7e5e61b487d4fbe7e02fefb2a04659f",
"size": 588,
"blob": "/workspace/vshell/dist/manifest.json"
}
}
+1
View File
@@ -0,0 +1 @@
{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","size":2588,"digest":"sha256:552ac268102d52c36ef9d74f11946f0b7464c6e49c545dbcba3acc2c3c3a1c1b"},"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","size":29764422,"digest":"sha256:a60ef8ec184edb5ed91f5983f00261359fb2fe20f603e8d488b9477d32fcad70"},{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","size":25222922,"digest":"sha256:7e7fd717d7775db6e6eb2e3171d45ae8b748ecbff4ce10aa263ea08199e8284f"}]}
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# VShell container entrypoint.
#
# VShell ships three forking daemons (systemd Type=forking). This script seeds
# the configuration on first start, creates the PKCS#12 certificate the
# HTTPS/FTPS listeners require, launches all three daemons, and then stays in
# the foreground as PID 1 so the container lives exactly as long as the
# services do.
set -euo pipefail
CFG=${VSHELL_CONFIG:-/usr/local/etc/vshelld_config}
PRIMES=/usr/local/etc/vshelld_primes.txt
CERT=${VSHELL_TLS_P12:-/usr/local/etc/vshell-https.p12}
SSH_ADDR=${VSHELL_SSH_ADDR:-0.0.0.0:22}
HTTPS_ADDR=${VSHELL_HTTPS_ADDR:-0.0.0.0:443}
HTTP_ADDR=${VSHELL_HTTP_ADDR:-0.0.0.0:80}
FTPS_EXPLICIT_ADDR=${VSHELL_FTPS_EXPLICIT_ADDR:-0.0.0.0:21}
FTPS_IMPLICIT_ADDR=${VSHELL_FTPS_IMPLICIT_ADDR:-0.0.0.0:990}
# Seed the configuration exactly the way the vendor postinst does.
[ -f "$CFG" ] || { echo "seeding $CFG from vshelld_config.default"; cp /usr/local/etc/vshelld_config.default "$CFG"; }
[ -f "$PRIMES" ] || { echo "seeding $PRIMES from vshelld_primes.txt.default"; cp /usr/local/etc/vshelld_primes.txt.default "$PRIMES"; }
# HTTPS and implicit FTPS need a PKCS#12 bundle with an empty passphrase;
# VShell cannot read PEM and has no way to supply a passphrase.
if [ ! -f "$CERT" ]; then
echo "generating self-signed certificate $CERT"
tmp=$(mktemp -d)
openssl req -x509 -newkey rsa:2048 -nodes -days 825 \
-subj "/CN=$(hostname)/O=VShell Container" \
-keyout "$tmp/key.pem" -out "$tmp/cert.pem" >/dev/null 2>&1
openssl pkcs12 -export -out "$CERT" -inkey "$tmp/key.pem" -in "$tmp/cert.pem" -passout pass: >/dev/null 2>&1
rm -rf "$tmp"
chmod 600 "$CERT"
fi
# Add the container's listen addresses unless the mounted configuration
# already defines them.
if ! grep -qE '^[[:space:]]*HTTPSListenAddresses' "$CFG"; then
echo "adding container listen addresses to $CFG"
cat >>"$CFG" <<EOF
# Container defaults added by vshell-entrypoint.
ListenAddresses { $SSH_ADDR }
HTTPListenAddresses {{ Address $HTTP_ADDR }}
HTTPSListenAddresses {{ Address $HTTPS_ADDR, CertificatePath $CERT }}
FTPSExplicitListenAddressesV2 {{ Address $FTPS_EXPLICIT_ADDR, CertificatePath $CERT, RequiredEncryptionLevel 0 }}
FTPSImplicitListenAddressesV2 {{ Address $FTPS_IMPLICIT_ADDR, CertificatePath $CERT, RequiredEncryptionLevel 2 }}
EOF
fi
for d in vshelld vshell-httpsd vshell-ftpsd; do
echo "starting $d"
"/usr/local/sbin/$d" -f "$CFG"
done
stop() {
for d in vshelld vshell-httpsd vshell-ftpsd; do
if [ -f "/var/run/$d.pid" ]; then
kill "$(cat "/var/run/$d.pid")" 2>/dev/null || true
fi
done
}
trap 'echo "stopping VShell"; stop; exit 0' TERM INT
while :; do
sleep 5
for d in vshelld vshell-httpsd vshell-ftpsd; do
if [ ! -f "/var/run/$d.pid" ]; then
echo "$d exited; stopping container" >&2
stop
exit 1
fi
done
done
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Install the vendor .deb into an extracted ubuntu:24.04 rootfs and prepare it as
# a container filesystem. This is the no-container-runtime build path used to
# produce the published image; with Docker/Podman use the Dockerfile instead.
#
# Usage: docker/install-rootfs.sh /path/to/rootfs
#
# The rootfs must be an extraction of the ubuntu:24.04 layer:
# tar -xzf /tmp/vshell-base/layer-00.tar -C "$ROOTFS" --numeric-owner
set -euo pipefail
ROOTFS=${1:?usage: install-rootfs.sh <rootfs>}
REPO=$(cd "$(dirname "$0")/.." && pwd)
DEB=$(ls "$REPO"/vendor/vshell-https-*.deb)
[ -d "$ROOTFS/usr/bin" ] || { echo "not an ubuntu rootfs: $ROOTFS" >&2; exit 1; }
cp "$DEB" "$ROOTFS/tmp/vshell.deb"
mkdir -p "$ROOTFS/tmp/stub"
printf '#!/bin/sh\necho "[systemctl stub] $*"\nexit 0\n' >"$ROOTFS/tmp/stub/systemctl"
chmod 0755 "$ROOTFS/tmp/stub/systemctl"
install -m 0755 "$REPO/docker/entrypoint.sh" "$ROOTFS/usr/local/bin/vshell-entrypoint"
# chroot into the rootfs. As an unprivileged user this needs a user namespace
# plus a bind-mounted /dev (mknod is not permitted inside one).
CHROOT_CMD='
export DEBIAN_FRONTEND=noninteractive PATH=/tmp/stub:$PATH
dpkg -i /tmp/vshell.deb
apt-get update -qq -o APT::Sandbox::User=root
apt-get install -y --no-install-recommends -o APT::Sandbox::User=root openssl
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*.deb
rm -rf /tmp/vshell.deb /tmp/stub
: > /etc/resolv.conf
rm -f /var/run/*.pid /var/lib/dpkg/lock* /usr/local/etc/vshelld_hostkey /usr/local/etc/vshelld_hostkey.pub /usr/local/etc/vshell-https.pem /usr/local/etc/vshell-https.p12
/usr/local/sbin/vshelld -version
'
# apt needs a resolv.conf and a /proc to work inside the chroot
cp /etc/resolv.conf "$ROOTFS/etc/resolv.conf"
if [ "$(id -u)" -eq 0 ]; then
mount -t proc proc "$ROOTFS/proc" || true
trap 'umount "$ROOTFS/proc" 2>/dev/null || true' EXIT
chroot "$ROOTFS" /bin/bash -c "$CHROOT_CMD"
else
unshare -rmpf --mount-proc="$ROOTFS/proc" sh -c '
rootfs=$1
for d in null zero random urandom tty; do
[ -e "$rootfs/dev/$d" ] || touch "$rootfs/dev/$d"
mount --bind "/dev/$d" "$rootfs/dev/$d"
done
chroot "$rootfs" /bin/bash -c "$2"
' sh "$ROOTFS" "$CHROOT_CMD"
fi
echo "rootfs ready: $ROOTFS"
+323
View File
@@ -0,0 +1,323 @@
#!/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')}`);
+54
View File
@@ -0,0 +1,54 @@
// Fetch the ubuntu:24.04 linux/amd64 base image (manifest, config, layers) from Docker Hub.
import { writeFile, mkdir } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { createWriteStream } from 'node:fs';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
const REPO = 'library/ubuntu';
const TAG = process.env.BASE_TAG || '24.04';
const OUT = process.env.OUT || '/tmp/vshell-base';
const PLATFORM = { os: 'linux', arch: 'amd64' };
const j = async (r) => { if (!r.ok) throw new Error(`${r.status} ${r.statusText} ${await r.text().catch(()=>'')}`.slice(0,300)); return r.json(); };
const tok = await j(await fetch(`https://auth.docker.io/token?service=registry.docker.io&scope=repository:${REPO}:pull`));
const H = {
Authorization: `Bearer ${tok.token}`,
Accept: [
'application/vnd.oci.image.index.v1+json',
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.oci.image.manifest.v1+json',
'application/vnd.docker.distribution.manifest.v2+json',
].join(', '),
};
let m = await j(await fetch(`https://registry-1.docker.io/v2/${REPO}/manifests/${TAG}`, { headers: H }));
if (m.manifests) {
const d = m.manifests.find((x) => x.platform?.os === PLATFORM.os && x.platform?.architecture === PLATFORM.arch);
if (!d) throw new Error('no amd64 manifest in index');
m = await j(await fetch(`https://registry-1.docker.io/v2/${REPO}/manifests/${d.digest}`, { headers: H }));
}
await mkdir(OUT, { recursive: true });
await writeFile(`${OUT}/manifest.json`, JSON.stringify(m, null, 2));
// Keep the config blob byte-for-byte: registries verify its sha256 on push, and
// re-serialising the parsed JSON would not reproduce the original bytes.
const cfgRes = await fetch(`https://registry-1.docker.io/v2/${REPO}/blobs/${m.config.digest}`, { headers: H });
if (!cfgRes.ok) throw new Error(`config blob: ${cfgRes.status}`);
const cfgBuf = Buffer.from(await cfgRes.arrayBuffer());
const cfgDigest = 'sha256:' + createHash('sha256').update(cfgBuf).digest('hex');
if (cfgDigest !== m.config.digest) throw new Error(`config digest mismatch: ${cfgDigest} != ${m.config.digest}`);
await writeFile(`${OUT}/config.raw.json`, cfgBuf);
const cfg = JSON.parse(cfgBuf.toString('utf8'));
await writeFile(`${OUT}/config.json`, JSON.stringify(cfg, null, 2));
const layers = [];
for (const [i, l] of m.layers.entries()) {
const file = `${OUT}/layer-${String(i).padStart(2, '0')}.tar`;
const r = await fetch(`https://registry-1.docker.io/v2/${REPO}/blobs/${l.digest}`, { headers: H });
if (!r.ok) throw new Error(`layer ${i}: ${r.status}`);
await pipeline(Readable.fromWeb(r.body), createWriteStream(file));
layers.push({ index: i, mediaType: l.mediaType, digest: l.digest, size: l.size, file });
console.log(`layer ${i} ${l.mediaType} ${l.size} ${l.digest}`);
}
await writeFile(`${OUT}/layers.json`, JSON.stringify({ repo: `${REPO}:${TAG}`, config: m.config, layers }, null, 2));
console.log('config.digest', m.config.digest);
console.log('DONE');
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env node
// Push an image built by tools/build-image.mjs to a Docker Registry v2 endpoint
// (this deployment uses the Gitea container registry).
//
// Usage:
// node tools/push-image.mjs --summary dist/image-summary.json \
// --registry https://gitea.milesward.dev --repo mward4/vshell \
// --user mward4 --token <token>
//
// Credentials may also come from GITEA_USER / GITEA_TOKEN.
import { readFile } from 'node:fs/promises';
import path from 'node:path';
const args = {};
for (let i = 2; i < process.argv.length; i += 2) {
if (!process.argv[i].startsWith('--')) throw new Error(`unexpected argument ${process.argv[i]}`);
args[process.argv[i].replace(/^--/, '')] = process.argv[i + 1];
}
const SUMMARY = path.resolve(args.summary || 'dist/image-summary.json');
const REGISTRY = (args.registry || 'https://gitea.milesward.dev').replace(/\/+$/, '');
const REPO = args.repo || 'mward4/vshell';
const USER = args.user || process.env.GITEA_USER || 'mward4';
const TOKEN = args.token || process.env.GITEA_TOKEN;
if (!TOKEN) throw new Error('missing --token / GITEA_TOKEN');
const AUTH = { Authorization: 'Basic ' + Buffer.from(`${USER}:${TOKEN}`).toString('base64') };
const summary = JSON.parse(await readFile(SUMMARY, 'utf8'));
const manifestBuf = await readFile(summary.manifest.blob);
const manifest = JSON.parse(manifestBuf);
// Every blob the manifest references must exist in the target repository.
const blobs = [
{ digest: summary.base.configDigest, file: summary.base.configBlob },
{ digest: summary.base.digest, file: summary.base.blob },
{ digest: summary.config.digest, file: summary.config.blob },
{ digest: summary.layer.digest, file: summary.layer.blob },
];
for (const b of blobs) {
const data = await readFile(b.file);
const head = await fetch(`${REGISTRY}/v2/${REPO}/blobs/${b.digest}`, { method: 'HEAD', headers: AUTH });
if (head.status === 200) {
console.log(`exists ${b.digest} (${(data.length / 1048576).toFixed(1)} MiB)`);
continue;
}
const start = await fetch(`${REGISTRY}/v2/${REPO}/blobs/uploads/`, { method: 'POST', headers: AUTH });
if (start.status !== 202) throw new Error(`upload start failed: ${start.status} ${await start.text()}`);
const location = new URL(start.headers.get('location'), REGISTRY);
location.searchParams.set('digest', b.digest);
const put = await fetch(location, {
method: 'PUT',
headers: { ...AUTH, 'Content-Type': 'application/octet-stream', 'Content-Length': String(data.length) },
body: data,
});
if (put.status !== 201) throw new Error(`blob upload failed: ${put.status} ${await put.text()}`);
console.log(`uploaded ${b.digest} (${(data.length / 1048576).toFixed(1)} MiB)`);
}
for (const tag of summary.tags) {
const ref = tag.includes(':') ? tag.split(':')[1] : tag;
const put = await fetch(`${REGISTRY}/v2/${REPO}/manifests/${ref}`, {
method: 'PUT',
headers: { ...AUTH, 'Content-Type': manifest.mediaType, 'Content-Length': String(manifestBuf.length) },
body: manifestBuf,
});
if (!put.ok) throw new Error(`manifest push failed: ${put.status} ${await put.text()}`);
console.log(`pushed ${REPO}:${ref} -> ${put.headers.get('docker-content-digest')}`);
}
// Read it back so there is independent evidence of what landed.
for (const tag of summary.tags) {
const ref = tag.includes(':') ? tag.split(':')[1] : tag;
const check = await fetch(`${REGISTRY}/v2/${REPO}/manifests/${ref}`, { headers: { ...AUTH, Accept: manifest.mediaType } });
if (!check.ok) throw new Error(`pull-back failed for ${ref}: ${check.status}`);
const got = check.headers.get('docker-content-digest');
const body = await check.json();
const ok = got === summary.manifest.digest && JSON.stringify(body) === JSON.stringify(manifest);
console.log(`verified ${REPO}:${ref} -> ${got} ${ok ? 'OK' : 'MISMATCH (expected ' + summary.manifest.digest + ')'}`);
if (!ok) process.exitCode = 1;
}
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# Validate a VShell container rootfs without a container runtime.
#
# Runs the vendor binaries, checks the configuration, then starts the image's
# real entrypoint (docker/entrypoint.sh) inside a user-namespace chroot on
# high ports and probes every listener from the outside.
#
# Usage: tools/validate.sh [/tmp/vshell-rootfs]
#
# Exit status 0 means every check passed.
set -uo pipefail
ROOTFS=${1:-/tmp/vshell-rootfs}
CFG=/tmp/validate_config
CERT=/tmp/validate.p12
SSH_PORT=2222 FTPS_PORT=2121 FTPSI_PORT=2990 HTTP_PORT=8080 HTTPS_PORT=8443
FAILED=0
say() { printf '%s\n' "$*"; }
fail() { printf 'FAIL: %s\n' "$*"; FAILED=1; }
[ -x "$ROOTFS/usr/local/sbin/vshelld" ] || { say "no VShell install under $ROOTFS"; exit 1; }
rm -f "$ROOTFS$CFG" "$ROOTFS$CERT"
# The chroot needs /dev entries for the daemons and openssl to behave normally.
in_ns() {
unshare -rm sh -c "
for d in null zero random urandom tty; do
[ -e $ROOTFS/dev/\$d ] || touch $ROOTFS/dev/\$d
mount --bind /dev/\$d $ROOTFS/dev/\$d
done
$1
"
}
say '== binaries, configuration and license'
in_ns "chroot $ROOTFS /bin/bash -c '
set -e
/usr/local/sbin/vshelld -version
/usr/local/sbin/vshell-httpsd -version
/usr/local/sbin/vshell-ftpsd -version
/usr/local/sbin/vshelld -test_config -f /usr/local/etc/vshelld_config
/usr/local/sbin/vshelld -license
'" || fail 'version/config/license checks'
say '== starting services through the image entrypoint'
in_ns "chroot $ROOTFS /bin/bash -c '
exec env VSHELL_CONFIG=$CFG VSHELL_TLS_P12=$CERT \
VSHELL_SSH_ADDR=0.0.0.0:$SSH_PORT VSHELL_HTTPS_ADDR=0.0.0.0:$HTTPS_PORT \
VSHELL_HTTP_ADDR=0.0.0.0:$HTTP_PORT VSHELL_FTPS_EXPLICIT_ADDR=0.0.0.0:$FTPS_PORT \
VSHELL_FTPS_IMPLICIT_ADDR=0.0.0.0:$FTPSI_PORT \
/usr/local/bin/vshell-entrypoint
'" >/tmp/vshell-validate.log 2>&1 &
sleep 8
sed 's/^/ entrypoint: /' /tmp/vshell-validate.log
banner() { # port, label
local port=$1 label=$2 line
if ! exec 3<>"/dev/tcp/127.0.0.1/$port" 2>/dev/null; then fail "$label ($port): connection refused"; return; fi
if read -t 3 -r line <&3; then say "ok $label ($port): $line"; else fail "$label ($port): no banner"; fi
exec 3<&- 3>&-
}
banner "$SSH_PORT" 'SSH '
banner "$FTPS_PORT" 'FTPS '
http_probe() {
local port=$1 line
if ! exec 3<>"/dev/tcp/127.0.0.1/$port" 2>/dev/null; then fail "HTTP ($port): connection refused"; return; fi
printf 'GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3
if read -t 4 -r line <&3; then say "ok HTTP ($port): $line"; else fail "HTTP ($port): no response"; fi
exec 3<&- 3>&-
}
http_probe "$HTTP_PORT"
if command -v node >/dev/null; then
node -e '
const tls = require("tls");
const port = Number(process.argv[1]);
const s = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }, () => {
const c = s.getPeerCertificate();
console.log(`ok TLS (${port}): ${s.getProtocol()} CN=${c.subject && c.subject.CN}`);
s.destroy();
});
s.on("error", (e) => { console.log(`FAIL: TLS (${port}): ${e.message}`); process.exitCode = 1; });
s.setTimeout(6000, () => { console.log(`FAIL: TLS (${port}): timeout`); s.destroy(); process.exitCode = 1; });
' "$HTTPS_PORT" || FAILED=1
else
say 'skip TLS probe (node not available)'
fi
say '== stopping'
for p in $(ls /proc | grep -E '^[0-9]+$'); do
[ -r "/proc/$p/cmdline" ] || continue
c=$(tr '\0' ' ' <"/proc/$p/cmdline" 2>/dev/null) || continue
case "$c" in /usr/local/sbin/*) kill "$p" 2>/dev/null && say " killed pid $p";; esac
done
wait 2>/dev/null
if [ "$FAILED" -eq 0 ]; then say '== ALL CHECKS PASSED'; else say '== VALIDATION FAILED'; fi
exit "$FAILED"
Binary file not shown.
@@ -0,0 +1 @@
7123601aef3b772bca9e1089dd785953133c44f1e4f3037ba8fd1850aa57407c vshell-https-5.0.1-3985.ubuntu24-64.x86_64.deb