commit a6f8e679768b421a71d7c3d66a4c57026d342df9 Author: Miles Ward Date: Mon Oct 5 12:14:21 2026 -0400 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff82177 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..58471bc --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e831489 --- /dev/null +++ b/README.md @@ -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 ` 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. diff --git a/dist/config.json b/dist/config.json new file mode 100644 index 0000000..3b98094 --- /dev/null +++ b/dist/config.json @@ -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"} \ No newline at end of file diff --git a/dist/image-summary.json b/dist/image-summary.json new file mode 100644 index 0000000..44c2d58 --- /dev/null +++ b/dist/image-summary.json @@ -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" + } +} \ No newline at end of file diff --git a/dist/manifest.json b/dist/manifest.json new file mode 100644 index 0000000..674db37 --- /dev/null +++ b/dist/manifest.json @@ -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"}]} \ No newline at end of file diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..e415162 --- /dev/null +++ b/docker/entrypoint.sh @@ -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" </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 diff --git a/docker/install-rootfs.sh b/docker/install-rootfs.sh new file mode 100755 index 0000000..7f1aabc --- /dev/null +++ b/docker/install-rootfs.sh @@ -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 } +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" diff --git a/tools/build-image.mjs b/tools/build-image.mjs new file mode 100755 index 0000000..bc1d3bb --- /dev/null +++ b/tools/build-image.mjs @@ -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')}`); diff --git a/tools/fetch-base.mjs b/tools/fetch-base.mjs new file mode 100755 index 0000000..83e5f83 --- /dev/null +++ b/tools/fetch-base.mjs @@ -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'); diff --git a/tools/push-image.mjs b/tools/push-image.mjs new file mode 100755 index 0000000..64e9306 --- /dev/null +++ b/tools/push-image.mjs @@ -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 +// +// 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; +} diff --git a/tools/validate.sh b/tools/validate.sh new file mode 100755 index 0000000..e5554f9 --- /dev/null +++ b/tools/validate.sh @@ -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" diff --git a/vendor/vshell-https-5.0.1-3985.ubuntu24-64.x86_64.deb b/vendor/vshell-https-5.0.1-3985.ubuntu24-64.x86_64.deb new file mode 100644 index 0000000..96269d4 Binary files /dev/null and b/vendor/vshell-https-5.0.1-3985.ubuntu24-64.x86_64.deb differ diff --git a/vendor/vshell-https-5.0.1-3985.ubuntu24-64.x86_64.deb.sha256 b/vendor/vshell-https-5.0.1-3985.ubuntu24-64.x86_64.deb.sha256 new file mode 100644 index 0000000..d2775f3 --- /dev/null +++ b/vendor/vshell-https-5.0.1-3985.ubuntu24-64.x86_64.deb.sha256 @@ -0,0 +1 @@ +7123601aef3b772bca9e1089dd785953133c44f1e4f3037ba8fd1850aa57407c vshell-https-5.0.1-3985.ubuntu24-64.x86_64.deb