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:
commit
a6f8e67976
14 files changed
+867
No files matched your search
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
Vendored
+1
@@ -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"}
|
||||
Vendored
+31
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -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"}]}
|
||||
@@ -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
|
||||
Executable
+55
@@ -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"
|
||||
Executable
+323
@@ -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')}`);
|
||||
Executable
+54
@@ -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');
|
||||
Executable
+81
@@ -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;
|
||||
}
|
||||
Executable
+101
@@ -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
|
||||
Reference in new issue
Block a user