diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a46436c0..3626732bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,6 +115,7 @@ jobs: alpha_activation: ${{ steps.decide.outputs.alpha_activation }} vax_toolchain: ${{ steps.decide.outputs.vax_toolchain }} tcpip: ${{ steps.decide.outputs.tcpip }} + ssh_boot: ${{ steps.decide.outputs.ssh_boot }} steps: - name: Checkout uses: actions/checkout@v4 @@ -248,6 +249,23 @@ jobs: - 'tests/qemu/run_tcpip_reboot_e2e.sh' - 'tests/qemu/test_tcpip_reboot_e2e.sh' - 'tests/integration/test_tcpip_posture_guard.sh' + # vms-843a: the wrapped-OpenSSH cold-boot proof. Fires the ssh-boot-e2e + # gate on any PR touching the wrapped sshd build/glue, the aux server it + # rides, the boot image build, the SSH test overlay, or the SSH e2e. + # Scoped so unrelated PRs pay nothing for the ~heavy full-distro SSH boot. + ssh_boot: + - '.github/workflows/**' + - 'third-party/openssh/**' + - 'src/vmsssh/**' + - 'src/vmstcpip/**' + - 'src/vmsrms/**' + - 'src/libvmssys/**' + - 'distro/Dockerfile.bootable' + - 'distro/rootfs-test-ssh/**' + - 'distro/rootfs/vms/SYS0/SYSCOMMON/SYS$STARTUP/**' + - 'tests/qemu/run_ssh_boot_e2e.sh' + - 'tests/qemu/test_ssh_boot_e2e.sh' + - 'tests/integration/test_tcpip_posture_guard.sh' release: - '.github/workflows/**' - 'tools/cut-release.sh' @@ -447,6 +465,7 @@ jobs: echo "alpha_activation=${{ steps.filter.outputs.alpha_activation }}" echo "vax_toolchain=${{ steps.filter.outputs.vax_toolchain }}" echo "tcpip=${{ steps.filter.outputs.tcpip }}" + echo "ssh_boot=${{ steps.filter.outputs.ssh_boot }}" } >> "$GITHUB_OUTPUT" else { @@ -467,6 +486,7 @@ jobs: echo "alpha_activation=true" echo "vax_toolchain=true" echo "tcpip=true" + echo "ssh_boot=true" } >> "$GITHUB_OUTPUT" fi # ----------------------------------------------------------------------- @@ -1789,6 +1809,59 @@ jobs: fi exit "$rc" + # ----------------------------------------------------------------------- + # ssh-boot-e2e (rd vms-843a): the SECOND, harder service on the proven (B) + # aux-launch path -- the wrapped OpenSSH sshd. INETD binds :22 over BGn:, + # ACP-stages SYS$SYSTEM:VMSSSHD.EXE off the ODS-2 disk, and execs sshd -i on + # the accepted connection; an inbound password login for a SYSUAF user must + # land an authenticated DCL session (SSH -> SYSUAF -> DCL) on a real cold boot. + # Same vms-f834 hole-free trigger + gha-cache pattern as the daytime job; a 77 + # SKIP is a hard error (the gate must genuinely boot + ssh, never skip silently). + # SSH is staged ONLY by --build-arg OVMX_TEST_ENABLE_SSH=1 (layered-product; the + # shipped image ships no SSH), so this is the only build that opens :22. + # ----------------------------------------------------------------------- + ssh-boot-e2e: + name: SSH cold-boot e2e — inbound ssh lands an authenticated DCL session on a booted distro (vms-843a) + needs: changes + if: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + || (github.event_name == 'pull_request' && needs.changes.outputs.ssh_boot == 'true')) + && !(github.event_name == 'workflow_dispatch' && github.event.inputs.vax_p4_only == 'true') }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build SSH test-overlay bootable image (OVMX_TEST_ENABLE_SSH=1) + uses: docker/build-push-action@v6 + with: + context: . + file: distro/Dockerfile.bootable + push: false + load: true + tags: ovmx-boot-ssh:latest + build-args: | + OVMX_TEST_ENABLE_SSH=1 + # Reuses the shared gha layer cache: only the late SSH build + overlay + # layers differ from the shipped build, so the kernel + early build-static + # layers are cache hits (the wrapped-sshd configure+make is the added cost). + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: SSH cold-boot proof — INETD launches VMSSSHD (ACP-stage, sshd -i), inbound ssh lands DCL + run: | + set +e + OVMX_QEMU_FULL_E2E=1 OVMX_SSH_IMAGE=ovmx-boot-ssh:latest \ + tests/qemu/run_ssh_boot_e2e.sh + rc=$? + if [ "$rc" -eq 77 ]; then + echo "FATAL: ssh_boot_e2e SKIPPED in CI — this gate must actually boot + ssh, never skip silently." >&2 + exit 1 + fi + exit "$rc" + # ----------------------------------------------------------------------- # dcl-acceptance-e2e: BOOT-AND-RUN-COMMANDS DCL/SHOW acceptance gate. # diff --git a/distro/Dockerfile.bootable b/distro/Dockerfile.bootable index 03771ca70..97ac628d4 100644 --- a/distro/Dockerfile.bootable +++ b/distro/Dockerfile.bootable @@ -414,7 +414,15 @@ ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} RUN apt-get update && apt-get install -y --no-install-recommends \ cmake gcc make musl-tools linux-libc-dev \ kmod cpio binutils libelf1 \ + curl perl ca-certificates patch \ && rm -rf /var/lib/apt/lists/* +# curl/perl/ca-certificates/patch: BUILD-TIME deps of the vms-843a wrapped-OpenSSH +# harness ONLY (third-party/openssh/build-ssh-harness.sh fetches the OpenSSH +# tarball + builds the vendored static libcrypto with perl). Used only under +# --build-arg OVMX_TEST_ENABLE_SSH=1; the shipped build skips the SSH step, and +# none of these land in the shipped artifact (kernel + initramfs + ODS-2 disk) or +# the separate ubuntu test-harness runner stage. Same set the KE-test harness +# (tests/qemu/Dockerfile) installs for the identical build. # The from-source OVMX kernel (vms-448): its built tree (for external module # builds) + the bzImage that becomes the bootable vmlinuz + the exact @@ -521,6 +529,59 @@ RUN CC=musl-gcc sh tests/toolchain/mk_tcc_static.sh build-static/bin/TCC.EXE RUN cmake -B build-static -DBUILD_TESTS=ON >/dev/null && \ cmake --build build-static --target mmk_native --parallel $(nproc) 2>&1 +# -- vms-843a: wrapped OpenSSH sshd (VMSSSHD.EXE) + privsep tree, TEST OVERLAY ONLY. +# On real OpenVMS, SSH is a LAYERED PRODUCT -- the shipped base OS image ships no +# SSH. So VMSSSHD.EXE and its privsep tree are built + staged only when +# --build-arg OVMX_TEST_ENABLE_SSH=1 (the SSH cold-boot proof), which stands in +# for "SSH configured + enabled" (SSH-enable is an operator posture call, +# Baron-reserved). The build is heavy (build-ssh-harness.sh configure+make of +# OpenSSH), so it is gated OFF by default -- shipped and daytime builds skip it +# entirely and stay unchanged. The wrapped sshd's transport rides the executive +# BGn: seam and its auth is SYSUAF/Purdy (vms-0cd; --wrap hooks, unmodified +# OpenSSH source). OpenSSH 10.0p1 re-execs its privsep siblings sshd-session/ +# sshd-auth at the configure-baked absolute path /ovmxsshsrv/libexec and needs +# the privsep dir /ovmxsshsrv/empty, so those + a fresh host key + the sshd_config +# are assembled at /ovmxsshsrv here and copied onto the INITRAMFS (the booted +# Linux VFS) in the initramfs-slim stage below. VMSSSHD.EXE itself rides the +# proven SYS$SYSTEM: ACP-stage launch path (vms-21b); the privsep self-re-exec is +# SSH-specific residue beside that mechanism (rd vms-843a, conductor ruling). +ARG OVMX_TEST_ENABLE_SSH=0 +RUN if [ "$OVMX_TEST_ENABLE_SSH" = "1" ]; then \ + CC=musl-gcc WORK=/tmp/ovmx-ssh sh third-party/openssh/build-ssh-harness.sh > /tmp/sshh.log 2>&1 \ + || { echo "FATAL: SSH harness build failed"; tail -80 /tmp/sshh.log; exit 1; } && \ + SSSHD=$(sed -n 's/^OVMX_SRV_SSHD=//p' /tmp/sshh.log | tail -1) && \ + KEYGEN=$(sed -n 's/^OVMX_KEX_KEYGEN=//p' /tmp/sshh.log | tail -1) && \ + { [ -n "$SSSHD" ] && [ -x "$SSSHD" ]; } || { echo "FATAL: OVMX_SRV_SSHD ($SSSHD) not built"; exit 1; } && \ + { [ -x /ovmxsshsrv/libexec/sshd-session ] && [ -x /ovmxsshsrv/libexec/sshd-auth ]; } \ + || { echo "FATAL: privsep siblings missing at /ovmxsshsrv/libexec"; exit 1; } && \ + cp "$SSSHD" build-static/bin/VMSSSHD.EXE && chmod +x build-static/bin/VMSSSHD.EXE && \ + if readelf -d build-static/bin/VMSSSHD.EXE 2>/dev/null | grep -q NEEDED; then \ + echo "FATAL: VMSSSHD.EXE is not musl-static (has DT_NEEDED)"; exit 1; fi && \ + mkdir -p /ovmxsshsrv/etc /ovmxsshsrv/empty && chmod 755 /ovmxsshsrv/empty && \ + "$KEYGEN" -t ed25519 -f /ovmxsshsrv/etc/hostkey -N "" -q && chmod 600 /ovmxsshsrv/etc/hostkey && \ + printf '%s\n' \ + '# OVMX SSH cold-boot proof sshd_config (rd vms-843a, TEST OVERLAY only).' \ + '# Runs as a DETACHED daemon (sshd -D, the vms-9cc-proven model): sshd' \ + '# itself binds :22 and listen/accepts over the executive BGn: seam. The' \ + '# args (-D -e -f this-file) are injected by __wrap_main (ovmx_ssh_wrap.c)' \ + '# since OVMX DCL RUN cannot pass argv. AddressFamily inet -- BGn: is AF_INET.' \ + 'Port 22' \ + 'ListenAddress 0.0.0.0' \ + 'AddressFamily inet' \ + 'HostKey /ovmxsshsrv/etc/hostkey' \ + 'PasswordAuthentication yes' \ + 'PubkeyAuthentication yes' \ + 'PermitRootLogin yes' \ + 'KbdInteractiveAuthentication no' \ + 'StrictModes no' \ + 'LogLevel VERBOSE' \ + 'Subsystem sftp internal-sftp' \ + > /ovmxsshsrv/etc/sshd_config && \ + echo "OK: OVMX_TEST_ENABLE_SSH=1 -- VMSSSHD.EXE built (musl-static) + /ovmxsshsrv assembled (privsep siblings + host key + sshd_config)"; \ + else \ + echo "OVMX_TEST_ENABLE_SSH=0 -- no SSH built (shipped base ships no SSH; layered-product posture)"; \ + fi + # -- System tree staging (OS kit + distribution image source) ----------------- # All OVMX binaries assembled into a /vms tree. This is BUILD staging only -- # it is never packed into an initramfs (vms-1ab: the fat initramfs that used @@ -995,6 +1056,33 @@ RUN if [ "$OVMX_TEST_ENABLE_TCPIP" = "1" ]; then \ else \ echo "OVMX_TEST_ENABLE_TCPIP=0 -- shipped image (no aux-server image, no TCP/IP auto-start, no port open)"; \ fi +# vms-843a SSH cold-boot PROOF overlay (TEST-ONLY; default OFF => shipped image +# ships no SSH). With --build-arg OVMX_TEST_ENABLE_SSH=1 the proof build stages the +# wrapped OpenSSH server VMSSSHD.EXE into SYS$SYSTEM: (ODS-2) + swaps in the SSH +# overlay SYSTARTUP (brings the NIC up, then @SYS$STARTUP:TCPIP$SSH_STARTUP). SSH is +# a DETACHED DAEMON (real VMS runs it via TCPIP$SSH_STARTUP RUN/DETACHED, not as an +# inetd service): TCPIP$SSH_STARTUP RUN/DETACHEDs SYS$SYSTEM:VMSSSHD.EXE, which DCL +# RUN ACP-stages off the ODS-2 disk (the proven vms-21b rms_stage_over_acp path) +# and activates; __wrap_main injects `-D -e -f ` (RUN can't pass argv), so +# sshd binds :22 + listen/accepts over BGn: itself (the vms-9cc-proven -D model). +# The privsep siblings + host key + sshd_config are placed on the initramfs (below). +# No INETD here -- SSH is not an inetd accept-handoff service. A default build +# leaves /system-stage/vms untouched (posture-neutral: shipped ships no SSH, opens +# no port; SSH-as-layered-product + SSH-enable stay operator posture calls). +# image_parity.py (vms-e1d) excludes this --build-arg-gated block from the shipped +# set, so VMSSSHD.EXE here is not x86_64-only shipped drift. +RUN if [ "$OVMX_TEST_ENABLE_SSH" = "1" ]; then \ + cp distro/rootfs-test-ssh/vms/SYS0/SYSCOMMON/SYSMGR/SYSTARTUP_VMS.COM \ + /system-stage/vms/SYS0/SYSCOMMON/SYSMGR/SYSTARTUP_VMS.COM && \ + cp /src/build-static/bin/VMSSSHD.EXE \ + /system-stage/vms/SYS0/SYSCOMMON/SYSEXE/ && \ + grep -q 'TCPIP\$SSH_STARTUP' /system-stage/vms/SYS0/SYSCOMMON/SYSMGR/SYSTARTUP_VMS.COM && \ + test -f /system-stage/vms/SYS0/SYSCOMMON/SYSEXE/VMSSSHD.EXE && \ + test -f '/system-stage/vms/SYS0/SYSCOMMON/SYS$STARTUP/TCPIP$SSH_STARTUP.COM' && \ + echo "OK: OVMX_TEST_ENABLE_SSH=1 -- SSH cold-boot proof overlay staged (VMSSSHD.EXE at SYS$SYSTEM:, detached-daemon SYSTARTUP -> TCPIP$SSH_STARTUP)"; \ + else \ + echo "OVMX_TEST_ENABLE_SSH=0 -- shipped image (no SSH image, no auto-start, no port open)"; \ + fi RUN /src/build-static/bin/vmsfs_master --ods2 master \ /boot/ovmx-distrib.img OVMXSYS /system-stage/vms 128 && \ /src/build-static/bin/vmsfs_master --ods2 list /boot/ovmx-distrib.img > /tmp/distrib-list.txt && \ @@ -1170,6 +1258,28 @@ RUN mkdir -p /initramfs-slim/dev /initramfs-slim/proc /initramfs-slim/sys \ # name "slim" explicitly (tests/qemu/test_distrib_boot.sh and friends) # keep working -- both names carry byte-identical content, produced by # this one pack step, not a second build. + # vms-843a: SSH overlay ONLY -- place the wrapped-sshd privsep tree on the + # INITRAMFS (the booted Linux VFS) at the OpenSSH configure-baked /ovmxsshsrv + # paths, so VMSSSHD.EXE (ACP-staged from SYS$SYSTEM: by INETD) can re-exec its + # privsep siblings sshd-session/sshd-auth (--libexecdir=/ovmxsshsrv/libexec), + # use the privsep dir /ovmxsshsrv/empty, and read the host key + sshd_config. + # A shipped build (OVMX_TEST_ENABLE_SSH=0) copies nothing -> the initramfs is + # unchanged and stays byte-reproducible (vms-d73). Done before the mtime + # normalize + cpio pack so these entries are normalized + packed too. + if [ "$OVMX_TEST_ENABLE_SSH" = "1" ]; then \ + cp -a /ovmxsshsrv /initramfs-slim/ovmxsshsrv && \ + { [ -x /initramfs-slim/ovmxsshsrv/libexec/sshd-session ] && \ + [ -x /initramfs-slim/ovmxsshsrv/libexec/sshd-auth ] && \ + [ -f /initramfs-slim/ovmxsshsrv/etc/hostkey ] && \ + [ -f /initramfs-slim/ovmxsshsrv/etc/sshd_config ]; } \ + || { echo "FATAL: SSH privsep tree incomplete on initramfs"; exit 1; } && \ + printf '%s\n' \ + 'root:x:0:0:root:/:/bin/false' \ + 'sshd:x:74:74:sshd privsep:/ovmxsshsrv/empty:/bin/false' \ + > /initramfs-slim/etc/passwd && \ + printf '%s\n' 'root:x:0:' 'sshd:x:74:' > /initramfs-slim/etc/group && \ + echo "OK: SSH privsep tree + /etc/passwd sshd privsep user staged on initramfs (/ovmxsshsrv: siblings + host key + sshd_config)"; \ + else echo "OVMX_TEST_ENABLE_SSH=0 -- no SSH tree on initramfs (shipped)"; fi && \ find /initramfs-slim -exec touch -h -d "@${SOURCE_DATE_EPOCH}" {} + && \ cd /initramfs-slim && find . | sort | cpio -o -H newc --reproducible 2>/dev/null | gzip -n > /boot/initramfs-ovmx.cpio.gz && \ cp /boot/initramfs-ovmx.cpio.gz /boot/initramfs-ovmx-slim.cpio.gz && \ @@ -1248,8 +1358,13 @@ ENV DEBIAN_FRONTEND=noninteractive # kmod: provides `modinfo`, which tests/qemu/test_intree_modules.sh (vms-934) # runs on the shipped vms.ko to prove it is stamped intree=Y. Test # tooling only (CLAUDE.md Rule 9 distinction), like `expect` above. +# openssh-client + sshpass: the DRIVER side of the vms-843a SSH cold-boot proof +# (tests/qemu/test_ssh_boot_e2e.sh) -- a standard client connects inbound to the +# OVMX guest's wrapped sshd over the QEMU hostfwd. Test tooling only; the shipped +# OVMX artifact is the kernel + initramfs + ODS-2 disk, not this ubuntu harness. RUN apt-get update && apt-get install -y --no-install-recommends \ qemu-system-x86 qemu-system-arm qemu-efi-aarch64 cpio expect kmod \ + openssh-client sshpass \ && rm -rf /var/lib/apt/lists/* COPY --from=builder /boot/vmlinuz /boot/vmlinuz diff --git a/distro/rootfs-test-ssh/vms/SYS0/SYSCOMMON/SYSMGR/SYSTARTUP_VMS.COM b/distro/rootfs-test-ssh/vms/SYS0/SYSCOMMON/SYSMGR/SYSTARTUP_VMS.COM new file mode 100644 index 000000000..4ba523932 --- /dev/null +++ b/distro/rootfs-test-ssh/vms/SYS0/SYSCOMMON/SYSMGR/SYSTARTUP_VMS.COM @@ -0,0 +1,113 @@ +$! SYSTARTUP_VMS.COM - Site-specific system startup command procedure +$! +$! Invoked by SYS$MANAGER:STARTUP.COM at the end of system startup, after +$! the system-wide logical names are defined. This is where a system +$! manager starts the services this node should run. Edits on the system +$! disk persist across reboots. +$! +$! HOW TO START ONE. A service is a DETACHED process with a VMS process +$! name, created by its own startup procedure: +$! +$! $ @SYS$STARTUP:_STARTUP.COM +$! +$! and that procedure runs: +$! +$! $ RUN /DETACHED - +$! /PROCESS_NAME= - +$! /INPUT=NL: /OUTPUT=SYS$MANAGER:.LOG - +$! SYS$SYSTEM:.EXE +$! +$! The name is assigned by the EXECUTIVE as the process is created, not +$! claimed by the process afterwards, so SHOW SYSTEM lists the service +$! and $GETJPI resolves it by name from any process on the node. A +$! duplicate name in the same UIC group is refused to the caller with +$! +$! %RUN-F-CREPRC, process creation failed +$! -SYSTEM-F-DUPLNAM, duplicate name +$! +$! Do NOT start a service from STARTUP.EXE (PID 1). It has no way to name +$! the process, no way for a manager to disable it, and its missing-image +$! check was silent -- see the NOTE ON SERVICES in src/ovmx_init/ovmx_init.c. +$! +$! JOB_CONTROL is the worked example of the RUN/DETACHED shape above, but +$! it is no longer STARTED from this file (see below) -- it is registered +$! as a STDRV component in SYS$STARTUP:VMS$VMS.DAT instead (vms-2a9), at +$! the END phase (after this file runs, preserving the same console-safe +$! ordering this file's own hardcoded call used to produce -- see +$! VMS$VMS.DAT's header for why). A layered-product or site-specific +$! service still belongs here. +$! +$ SET NOON +$! +$! REGISTER THE SHAREABLE IMAGES every dynamically-activated OVMX binary +$! depends on with INSTALL (bead vms-913.7, docs/design-image-activation.md +$! section 6). This is Priority 1 of IMGACT.EXE's SONAME search order +$! (src/imgact/imgact.c load_needed()): a hit here is an O(1) mmap'd hash +$! lookup with no filesystem search at all; a system that never runs this +$! still boots, because Priority 2 (the hardcoded SYS$SHARE fallback) always +$! finds the same files by scanning the directory instead. INSTALL ADD is +$! idempotent (re-adding an installed image updates its entry in place), so +$! running this on every boot -- not just the first -- is the VMS way and +$! matches the real SYSTARTUP_VMS.COM's own STARTUP.COM pattern. +$! +$! This list is exactly the shareable set the fat initramfs actually ships +$! in SYS$SHARE (distro/Dockerfile.bootable's "9 VMS-native LINK.EXE +$! artifacts" gate: these 7 shareables + DCL.EXE + LOGINOUT.EXE in SYSEXE). +$! LIBVMSQUEUE$SHR.EXE (src/vmsqueue) is deliberately NOT here: it is built +$! by the ordinary CMake add_library() graph, not the VMS-native LINK.EXE +$! graph, and is not copied into the fat initramfs -- an INSTALL ADD for it +$! would fail with %INSTALL-E-FILNOTFND on every boot. +$! +$ INSTALL ADD SYS$SHARE:DECC$SHR.EXE /OPEN /SHARED /HEADER_RESIDENT +$ INSTALL ADD SYS$SHARE:LIBVMSSYS$SHR.EXE /OPEN /SHARED /HEADER_RESIDENT +$ INSTALL ADD SYS$SHARE:LIBVMS$SHR.EXE /OPEN /SHARED /HEADER_RESIDENT +$ INSTALL ADD SYS$SHARE:LIBVMSPROCESS$SHR.EXE /OPEN /SHARED /HEADER_RESIDENT +$ INSTALL ADD SYS$SHARE:LIBVMSLNM$SHR.EXE /OPEN /SHARED /HEADER_RESIDENT +$ INSTALL ADD SYS$SHARE:LIBVMSFS$SHR.EXE /OPEN /SHARED /HEADER_RESIDENT +$ INSTALL ADD SYS$SHARE:LIBVMSRMS$SHR.EXE /OPEN /SHARED /HEADER_RESIDENT +$! +$! JOB_CONTROL (vms-8d2) -- the console-session process -- is NO LONGER +$! started here (vms-2a9, docs/design-boot-faithful.md sec3.8/sec4.5). It +$! is now a registered STDRV component (SYS$STARTUP:VMS$VMS.DAT, END +$! phase -- the LAST of the nine), created by STARTUP.COM's own +$! RUN_COMPONENTS subroutine after this file, and everything else STDRV +$! runs, has already finished. That preserves this file's own former +$! ordering exactly (JOB_CONTROL used to be the last real action of this +$! procedure) rather than moving JOB_CONTROL's creation earlier: an +$! earlier phase was tried first and measurably interleaved JOB_CONTROL's +$! LOGINOUT "Username:" prompt with this file's own INSTALL ADD console +$! output (see VMS$VMS.DAT's header for the full account). Because END +$! still runs after the Priority 1 INSTALL ADD table above, JOB_CONTROL's +$! own dynamic activation still resolves its dependencies at Priority 1, +$! unchanged from before this item. +$! +$! THE SITE-STARTUP ANNOUNCEMENT MOVED (vms-3ab8). "The OVMX system is now +$! executing the site-specific startup commands." used to be WRITTEN here AND +$! by STARTUP.COM's RUN_SITE_STARTUP subroutine -- the LPMAIN-phase driver that +$! invokes this file -- so a real boot printed it TWICE (the operator saw the +$! doubled line in the 0.4 demo boot). STARTUP.COM is the single owner now: it +$! prints the line immediately before @SYS$MANAGER:SYSTARTUP_VMS.COM, so the +$! duplicate WRITE is removed from here. The tests that assert the line appears +$! (tests/qemu/test_persistent_boot.sh, test_startup_phase_driver.sh, +$! test_job_control_*.sh) still find it -- STARTUP.COM prints it, exactly once. +$! Why the wording is not oracle-pinned is documented at STARTUP.COM's +$! RUN_SITE_STARTUP, the announcement's single owner. +$! +$! vms-843a SSH cold-boot PROOF (TEST OVERLAY only -- staged into a test +$! build via --build-arg OVMX_TEST_ENABLE_SSH=1; the SHIPPED SYSTARTUP_VMS.COM +$! is UNCHANGED, opens no port by default -- SSH-enable is an operator posture call, +$! Baron-reserved). Bring the NIC up the ordinary VMS way (SLIRP's single guest +$! lease 10.0.2.15), then start the auxiliary server so the enabled SSH service +$! (SYS$SYSTEM:VMSSSHD.EXE, ACP-staged) accepts inbound on :22 and lands DCL. +$! iface = the VMS DEVICE NAME "SE0", NOT the Linux kernel name. TCPIP SET +$! INTERFACE (src/vmsdcl/dcl_cmd_misc.c) enumerates /sys/class/net, maps each +$! substrate NIC to a VMS name (tcpip_map_interface: eth*/ens*/enp* -> SE0, SE1, +$! ...), and matches its argument against THAT VMS name -- so it wants "SE0", not +$! the Linux name "eth0" and not the VMS-view "ETH0:". Both of those yielded +$! %TCPIP-E-NOSUCHDEV (the VMS-name lookup missed). "SE0" is the first ethernet- +$! class NIC regardless of whether the kernel named it eth0/ens0/enp0s2. 10.0.2.15 +$! is QEMU SLIRP's conventional single-guest lease, reachable via the hostfwd. +$ @SYS$MANAGER:TCPIP$CONFIG OVMX OVMX.LOCAL SE0 10.0.2.15 255.255.255.0 +$ TCPIP SHOW INTERFACE ! diagnostic: confirm SE0 came up with 10.0.2.15 on the console +$ @SYS$STARTUP:TCPIP$SSH_STARTUP ! start the wrapped OpenSSH sshd as a detached daemon (vms-843a) +$ EXIT diff --git a/distro/rootfs/vms/SYS0/SYSCOMMON/SYS$STARTUP/TCPIP$SSH_STARTUP.COM b/distro/rootfs/vms/SYS0/SYSCOMMON/SYS$STARTUP/TCPIP$SSH_STARTUP.COM new file mode 100644 index 000000000..492134677 --- /dev/null +++ b/distro/rootfs/vms/SYS0/SYSCOMMON/SYS$STARTUP/TCPIP$SSH_STARTUP.COM @@ -0,0 +1,57 @@ +$! TCPIP$SSH_STARTUP.COM - OVMX SSH server (VMSSSHD) startup procedure (rd vms-843a). +$! +$! On real OpenVMS, TCP/IP Services' SSH server runs as a PERSISTENT DETACHED +$! DAEMON started by its own startup procedure -- NOT as an auxiliary (inetd) +$! service. (The auxiliary server TCPIP$INETD carries the simple accept-handoff +$! wait-model services like DAYTIME; SSH, with long-lived interactive sessions and +$! its own concurrency, is a standalone daemon.) This procedure is the OVMX +$! equivalent: it starts VMSSSHD.EXE -- the wrapped, unmodified OpenSSH sshd -- as +$! a detached process. sshd itself binds :22 and listen/accepts over the executive +$! BGn: seam (the vms-0cd/vms-9cc-proven server transport, ovmx_socket/bind/listen/ +$! accept), authenticates passwords against the binary SYSUAF (Purdy), and drops +$! the session into LOGINOUT -> DCL. +$! +$! ARGS. OVMX DCL RUN cannot pass Unix argv (its RUN Image qualifiers are +$! oracle-verbatim: /DEBUG, /NODEBUG only), so VMSSSHD.EXE is launched argv-less +$! and its ovmx wrap (__wrap_main, third-party/openssh/ovmx/ovmx_ssh_wrap.c) +$! injects the daemon args `-D -e -f /ovmxsshsrv/etc/sshd_config`: -D keeps sshd in +$! the foreground of its detached process (no double-daemonize), -e logs to stderr, +$! and -f names the sshd_config the SSH overlay generated on the initramfs. +$! +$! INV-6 (Rule 9): sshd's listener binds over the executive BGn: device. With no +$! executive (/dev/vms absent) the wrapped socket()/bind() fail honestly +$! (SS$_NOSUCHDEV -> ENODEV) and VMSSSHD exits non-zero -- it never fabricates a +$! per-process listener that shares nothing. +$! +$! I/O goes to OPA0: (the operator console) so the daemon's -e log is visible on +$! the console -- the SSH wire data rides the BGn: socket sshd accepts, NOT this +$! stdout/stderr, so console logging cannot corrupt a session. (A production +$! deployment would point these at SYS$MANAGER:VMSSSHD.LOG instead; the SSH +$! cold-boot proof wants the log on the console it captures.) +$! +$! INVOCATION: +$! @SYS$STARTUP:TCPIP$SSH_STARTUP +$! +$! POSTURE (Baron-reserved): SSH is a layered product, OFF by default. The shipped +$! SYSTARTUP_VMS.COM does NOT invoke this procedure (the posture guard enforces +$! that); only the SSH test overlay's SYSTARTUP does. SSH-enable-by-default + a +$! shipped host key are operator posture decisions. +$! +$ SET NOON +$ SAY := WRITE SYS$OUTPUT +$ SAY "" +$ SAY " OVMX SSH Server -- Startup (TCPIP$SSH_STARTUP)" +$ SAY "" +$ VMSSSHD_IMAGE = "SYS$SYSTEM:VMSSSHD.EXE" +$! The SSH server is a persistent listener -- it MUST run DETACHED (a foreground +$! RUN would block system startup forever). The EXECUTIVE assigns the process name +$! VMSSSHD as it creates the process, so SHOW SYSTEM lists it and a duplicate is +$! refused to the caller (%RUN-F-CREPRC / -SYSTEM-F-DUPLNAM), making a re-run +$! idempotent-safe. No image argv is given here; __wrap_main supplies -D -e -f. +$ SAY "%SSH-I-STARTSRV, starting the SSH server (VMSSSHD) from ''VMSSSHD_IMAGE'" +$ RUN /DETACHED - + /PROCESS_NAME=VMSSSHD - + /INPUT=NL: /OUTPUT=OPA0: /ERROR=OPA0: - + ''VMSSSHD_IMAGE' +$ SAY "%SSH-I-SRVSTARTED, SSH server (VMSSSHD) started detached" +$ EXIT diff --git a/docs/design-ssh-loginout-handoff.md b/docs/design-ssh-loginout-handoff.md new file mode 100644 index 000000000..93ab9055e --- /dev/null +++ b/docs/design-ssh-loginout-handoff.md @@ -0,0 +1,104 @@ +# SSH → LOGINOUT/$CREPRC handoff (vms-843a final layer / vms-16b) + +Design prep for the last layer of the booted-distro SSH login. **Do not build until +vms-16b is greenlit — it is Baron-reserved (login-stack consolidation + retirement of +the SSH C-reimpl session shim).** This records the grounded design so the build is +fast on greenlight. (Conductor ruling 2026-09-10.) + +## What is already PROVEN (the banked milestone) + +On a booted OVMX distro (PR #1128, `ssh-boot-e2e`, console via OPA0:): +- The wrapped OpenSSH sshd (`VMSSSHD.EXE`) is ACP-staged from `SYS$SYSTEM:` and + launched as a **detached daemon** (`sshd -D`, the vms-9cc-proven model) by + `TCPIP$SSH_STARTUP` (RUN/DETACHED; `__wrap_main` injects `-D -e -f`). +- `sshd -D` **binds :22 and listen/accepts over the executive BGn: seam** + (`Server listening on 0.0.0.0 port 22.` / `Connection from 10.0.2.2 … on + 10.0.2.15 port 22`). +- **privsep works** (`/etc/passwd` sshd privsep user on the initramfs). +- The inbound session reaches the login. + +Transport + auth-reachability + privsep are done. Only the **DCL-session +activation** remains. + +## The remaining fatal (why it is held) + +The SSH session shim (`third-party/openssh/ovmx/ovmx_sshd_exec.c` `__wrap_execve` + +`src/vmsssh/sshd_auth.c` `ovmx_sshd_fill_passwd`) sets the login user's shell to +`VMS_SYSTEM_DIR/DCL.EXE` and does a **raw `__real_execve("/vms/…/DCL.EXE", …)`**. +On a booted distro that path is **ODS-2/ACP-only, not on the initramfs Linux VFS**, +so sshd's shell-existence `stat()` fails: *"User SYSTEM not allowed because shell +/vms/SYS0/SYSCOMMON/SYSEXE/DCL.EXE does not exist"*. This is the **same class as +vms-21b's raw-execv bug**, now for DCL activation — and DCL is a VMS-native +`PT_INTERP=IMGACT` image (multi-image activation: DCL + IMGACT + shareables). + +Staging the DCL chain on the initramfs (the KE-test's approach) is **rejected** — +it is exactly the raw-execve-from-the-Linux-VFS anti-pattern vms-21b retired. + +## The faithful fix — hand off to LOGINOUT via $CREPRC (the existing primitive) + +DECnet SET HOST already does this, and there is a shared primitive to reuse: +`src/vmsdecnet/cterm/dnet_cterm_host.c` +``` +inbound connection (validated) + -> ovmx_vterm_create() # executive mints a virtual terminal RTAn: + -> $CREPRC(LOGINOUT.EXE, RTAn:, PRC$M_INTER|PRC$M_LOGINOUT) # uic=0, prvadr=NULL + -> LOGINOUT authenticates on that terminal, re-personas the process, activates DCL +``` +`$CREPRC` (`src/libvms/syssvc/sys_process.c`, `starlet.h`) creates the process the +VMS way (executive-resident), `creprc_bind_terminal` binds the connection to the +virtual terminal, and `PRC$M_LOGINOUT` starts LOGINOUT with no identity — it acquires +one only after its own SYSUAF authentication. The daemon never holds/forges a +credential. `tests/integration/test_creprc_session_primitive.sh` gates the primitive. + +So the SSH session should NOT raw-execve DCL (nor shim-IMGACT it): it should mint a +virtual terminal for the SSH connection (the accepted BGn:/materialized fd) and +`$CREPRC(LOGINOUT.EXE, , PRC$M_INTER|PRC$M_LOGINOUT)`, exactly as console +login (JOB_CONTROL → LOGINOUT) and DECnet SET HOST do. This unifies console + DECnet ++ SSH on one LOGINOUT/$CREPRC primitive and retires the SSH C-reimpl session shim +(the raw-execve DCL activation, the LOGINOUT-equivalent cred-drop in `cred_drop.c`, +the `sshd_session.c`/`ovmx_sshd_exec.c` shims). + +## The one open design question — AUTH (SSH already authed; DECnet's LOGINOUT auths) + +DECnet SET HOST has **no transport auth**: LOGINOUT does all the auth over the +terminal. SSH is different — the SSH protocol **requires** auth before a session +channel opens, and vms-9cc already built + proved sshd doing **in-protocol SYSUAF/ +Purdy password auth**. So the fork: + +- **Option A — sshd authenticates; LOGINOUT trusts it (network-login mode).** Keep + the proven vms-9cc in-protocol SYSUAF auth. `$CREPRC(LOGINOUT, , + PRC$M_INTER|PRC$M_LOGINOUT + a pre-authenticated/network flag)` passing the + SSH-authenticated username; LOGINOUT runs a **network-login mode** that trusts the + established identity (no re-challenge — SSH already consumed the auth exchange and + authed against the *same* SYSUAF/Purdy authority), then stamps identity + activates + DCL + session setup. **Requires a new LOGINOUT network-login mode** — OVMX has none + today (`tools/vms_login.c:115`: "OVMX has no batch/network login"). Lower risk + (builds on proven auth); reuses the $CREPRC(LOGINOUT) primitive; retires the shim's + DCL-activation but keeps the sshd SYSUAF-auth wrap. +- **Option B — LOGINOUT does ALL the auth (DECnet-identical).** sshd drops its + in-protocol SYSUAF auth; SSH keyboard-interactive is bridged to LOGINOUT's + challenge over the vterm, so LOGINOUT is the single auth authority for console + + DECnet + SSH and the daemon never holds a credential (the DECnet safety property). + Most unified + retires the most shim (including the sshd auth wrap), but a bigger + sshd auth re-architecture (keyboard-interactive → LOGINOUT bridge) and diverges + from the vms-9cc-proven in-protocol auth. + +**Recommendation:** A for the first vms-16b increment (keep proven auth, add a +LOGINOUT network-login mode, reuse $CREPRC(LOGINOUT,vterm), retire the DCL-activation +shim), with B as the fuller-consolidation follow-on if Baron wants LOGINOUT as the +sole auth authority. Either way the mechanism is `ovmx_vterm_create` + +`$CREPRC(LOGINOUT, vterm, PRC$M_INTER|PRC$M_LOGINOUT)`, and the SSH e2e proof is +unchanged: an inbound `ssh SYSTEM@` lands a real DCL `$` (`OVMX_DCL_LANDED_843a`). + +## Build checklist (on greenlight) +1. Mint a virtual terminal for the SSH connection (reuse `ovmx_vterm_create` / + the BGn:-fd → RTAn: bind used by DECnet). +2. Replace the shim's raw-execve-DCL with `$CREPRC(LOGINOUT.EXE, , + PRC$M_INTER|PRC$M_LOGINOUT)` (+ the network/pre-authed path for Option A). +3. (Option A) add the LOGINOUT network-login mode (`tools/vms_login.c`): trust a + pre-established identity, skip the password read, stamp + start_session. +4. Retire the SSH C-reimpl session shim (vms-16b): DCL-activation, cred_drop + LOGINOUT-equivalent, sshd_session/ovmx_sshd_exec — replaced by LOGINOUT. +5. Keep the SSH overlay/posture (shipped SSH-off) + the OPA0: diagnostic; the + `ssh-boot-e2e` goes green when the inbound login lands DCL. + diff --git a/tests/integration/test_tcpip_posture_guard.sh b/tests/integration/test_tcpip_posture_guard.sh index 264602251..7f8722637 100755 --- a/tests/integration/test_tcpip_posture_guard.sh +++ b/tests/integration/test_tcpip_posture_guard.sh @@ -31,7 +31,7 @@ FAIL=0 ok(){ echo " PASS: $*"; } bad(){ echo " FAIL: $*"; FAIL=1; } -echo "=== test_tcpip_posture_guard (shipped default auto-starts no network service, rd vms-21b) ===" +echo "=== test_tcpip_posture_guard (shipped default auto-starts no network service, rd vms-21b/vms-843a) ===" [ -f "$SYSTARTUP" ] || { echo "FATAL: shipped SYSTARTUP_VMS.COM not found at $SYSTARTUP"; exit 1; } [ -f "$SERVICE_DAT" ] || { echo "FATAL: shipped TCPIP\$SERVICE.DAT not found at $SERVICE_DAT"; exit 1; } @@ -55,5 +55,18 @@ else ok "shipped TCPIP\$SERVICE.DAT ships with every service DISABLED (no enabled line)" fi +# (c) SSH-specific posture (rd vms-843a): SSH runs as a DETACHED DAEMON started by +# @SYS$STARTUP:TCPIP$SSH_STARTUP (not an inetd service), so the shipped SSH-off +# posture is: the shipped SYSTARTUP must NOT invoke TCPIP$SSH_STARTUP. SSH-enable- +# by-default is a distinct, higher-stakes operator decision than daytime (a network +# login authority + a shipped host key), so assert it EXPLICITLY. A DCL invocation +# is a non-comment line ('$' col 1, not '$!') naming TCPIP$SSH_STARTUP. +if grep -nE '^\$[^!].*TCPIP\$SSH_STARTUP' "$SYSTARTUP" >/dev/null 2>&1; then + bad "shipped SYSTARTUP_VMS.COM INVOKES @SYS\$STARTUP:TCPIP\$SSH_STARTUP -- that auto-starts the SSH daemon at boot (network login authority + shipped host key, Baron-reserved). The SSH cold-boot proof enables SSH via the OVMX_TEST_ENABLE_SSH test overlay only; if enabling SSH by default is intended, make it explicit and update this guard." + grep -nE '^\$[^!].*TCPIP\$SSH_STARTUP' "$SYSTARTUP" | sed 's/^/ /' +else + ok "shipped SYSTARTUP_VMS.COM does NOT invoke TCPIP\$SSH_STARTUP (SSH daemon not auto-started -- SSH is a layered product, off by default, Baron-reserved)" +fi + echo "=== test_tcpip_posture_guard: $([ "$FAIL" = 0 ] && echo PASS || echo FAIL) ===" exit "$FAIL" diff --git a/tests/qemu/run_ssh_boot_e2e.sh b/tests/qemu/run_ssh_boot_e2e.sh new file mode 100755 index 000000000..1846d9340 --- /dev/null +++ b/tests/qemu/run_ssh_boot_e2e.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# run_ssh_boot_e2e.sh (rd vms-843a) -- build the SSH test-overlay bootable image +# (distro/Dockerfile.bootable --build-arg OVMX_TEST_ENABLE_SSH=1) and run the +# inbound SSH cold-boot proof (test_ssh_boot_e2e.sh) INSIDE it. Proves the aux +# server (TCPIP$INETD) binds :22 over BGn:, ACP-stages SYS$SYSTEM:VMSSSHD.EXE (the +# wrapped OpenSSH sshd, via the vms-21b ACP-stage->execve mechanism), and launches +# it in inetd mode on the accepted connection, so an inbound password login for a +# SYSUAF user lands an authenticated DCL session end-to-end (SSH -> SYSUAF -> DCL). +# +# Requires OVMX_QEMU_FULL_E2E=1 (a real docker build + QEMU boot -- real minutes). +# The CI job / rail runner MUST set it; a bare run SKIPs (77) rather than silently +# passing. The test-overlay build arg keeps the SHIPPED image unchanged (the arg +# defaults to 0 -- shipped ships no SSH); only this proof build enables SSH. +set -u +SKIP=77 + +[ "${OVMX_QEMU_FULL_E2E:-0}" = "1" ] || { + echo "SKIP: ssh_boot_e2e requires OVMX_QEMU_FULL_E2E=1 (real docker+QEMU boot)." + exit "$SKIP" +} +command -v docker >/dev/null 2>&1 || { echo "SKIP: docker not available"; exit "$SKIP"; } + +REPO_ROOT=$(cd "$(dirname "$0")/../.." && pwd) +IMAGE="${OVMX_SSH_IMAGE:-ovmx-boot-ssh}" + +if ! docker image inspect "$IMAGE" >/dev/null 2>&1 || [ "${OVMX_BUILD_BOOT_IMAGE:-0}" = "1" ]; then + echo "--- building SSH test-overlay bootable image ($IMAGE, OVMX_TEST_ENABLE_SSH=1) ---" + # BuildKit required: Dockerfile.bootable uses RUN --mount=type=secret (module + # signing key). No secret is supplied here -> the Dockerfile's else branch + # generates an ephemeral key (fine for a test-overlay image; the shipped + # release image's reproducibility is a separate gate). + DOCKER_BUILDKIT=1 docker build -t "$IMAGE" \ + --build-arg OVMX_TEST_ENABLE_SSH=1 \ + -f "$REPO_ROOT/distro/Dockerfile.bootable" "$REPO_ROOT" \ + || { echo "FATAL: SSH test-overlay image build failed"; exit 1; } +fi + +KVM_ARG="" +[ -w /dev/kvm ] && KVM_ARG="--device /dev/kvm" + +echo "--- booting + probing SSH (inbound :22 via hostfwd) ---" +docker run --rm $KVM_ARG \ + -v "$REPO_ROOT/tests/qemu/test_ssh_boot_e2e.sh:/test.sh:ro" \ + --entrypoint bash "$IMAGE" /test.sh diff --git a/tests/qemu/test_ssh_boot_e2e.sh b/tests/qemu/test_ssh_boot_e2e.sh new file mode 100755 index 000000000..8a3723563 --- /dev/null +++ b/tests/qemu/test_ssh_boot_e2e.sh @@ -0,0 +1,130 @@ +#!/bin/bash +# test_ssh_boot_e2e.sh (rd vms-843a) -- runs INSIDE the SSH test-overlay bootable +# image (built --build-arg OVMX_TEST_ENABLE_SSH=1). Cold-boots the OVMX distro +# under QEMU with the aux server auto-started (the SSH overlay's SYSTARTUP runs +# TCPIP$CONFIG then @SYS$STARTUP:TCPIP$STARTUP) and a SLIRP hostfwd to guest :22, +# then connects INBOUND with a standard ssh client and a SYSUAF password login and +# asserts the session lands in DCL -- proving SSH -> SYSUAF -> DCL end to end on a +# booted distro over the proven (B) aux-launch path: TCPIP$INETD accepts :22 over +# BGn:, ACP-stages SYS$SYSTEM:VMSSSHD.EXE off the ODS-2 disk, execs sshd -i on the +# accepted fd; the wrapped sshd authenticates the password against the binary +# SYSUAF (Purdy) and drops into LOGINOUT -> DCL, all bytes over the executive +# socket. No shell, no fork of upstream OpenSSH. +# +# This asserts a REAL cold boot: the shipped image ships no SSH and opens no port; +# only this test-overlay build (OVMX_TEST_ENABLE_SSH=1) stages + auto-starts SSH. +set -u +FAIL=0 +ok(){ echo " PASS: $*"; } +bad(){ echo " FAIL: $*"; FAIL=1; } + +MARKER="OVMX_DCL_LANDED_843a" + +# Boot artifacts the bootable image ships (distro/Dockerfile.bootable runner stage). +KERNEL=/boot/vmlinuz +INITRD=/boot/initramfs-ovmx.cpio.gz +[ -f "$INITRD" ] || INITRD=/boot/initramfs-ovmx-slim.cpio.gz +IMG=/boot/ovmx-distrib.img +for f in "$KERNEL" "$INITRD" "$IMG"; do + [ -f "$f" ] || { echo "FATAL: missing boot artifact $f (image /boot layout)"; ls -la /boot 2>/dev/null; exit 1; } +done +command -v ssh >/dev/null 2>&1 || { echo "FATAL: ssh client missing in the harness image"; exit 1; } +command -v sshpass >/dev/null 2>&1 || { echo "FATAL: sshpass missing in the harness image"; exit 1; } + +DISK=/tmp/ssh-e2e.img +cp "$IMG" "$DISK" # writable copy for -drive +LOG=/tmp/ssh-e2e-console.log +HOSTPORT=2222 # host side of the SLIRP forward -> guest :22 +rm -f "$LOG" + +ACCEL="-accel tcg" +[ -w /dev/kvm ] && ACCEL="-accel kvm -cpu host" + +# virtio-net NIC + inbound hostfwd (:2222 -> guest :22), same shape as the daytime +# proof. 768M: sshd's privsep re-exec (sshd-session/sshd-auth) + key exchange want +# a little more headroom than the tiny daytime service. +qemu-system-x86_64 \ + -kernel "$KERNEL" -initrd "$INITRD" \ + -nographic -append "console=ttyS0 loglevel=3 quiet" \ + -m 768M -smp 2 $ACCEL \ + -netdev "user,id=net0,hostfwd=tcp::${HOSTPORT}-:22" \ + -device virtio-net-pci,netdev=net0 \ + -nodefaults -serial stdio \ + -drive file="$DISK",format=raw,if=virtio,cache=writethrough \ + -no-reboot "$LOG" 2>&1 & +QPID=$! +trap 'kill $QPID 2>/dev/null' EXIT + +wait_for(){ # pattern limit-seconds + local pat="$1" lim="$2" i=0 + while [ "$i" -lt "$lim" ]; do + grep -qF "$pat" "$LOG" 2>/dev/null && return 0 + kill -0 "$QPID" 2>/dev/null || return 1 # qemu died + sleep 2; i=$((i+2)) + done + return 1 +} + +if wait_for '%OVMX-I-EXEC' 120; then + ok "executive attached on a real cold boot (vms.ko)" +else + bad "executive never attached within 120s" +fi + +# The SSH overlay SYSTARTUP brings the NIC up (TCPIP$CONFIG) and starts the SSH +# server (TCPIP$SSH_STARTUP -> RUN/DETACHED VMSSSHD.EXE, sshd -D) during LPMAIN, +# before the login prompt. +if wait_for '%SSH-I-SRVSTARTED' 180; then + ok "SSH server (VMSSSHD) started detached on cold boot" +else + bad "SSH server never started (TCPIP\$CONFIG / TCPIP\$SSH_STARTUP) -- see console tail" +fi + +sleep 5 # let sshd bind :22 + listen over BGn: + +# Inbound SSH: password login as SYSTEM (SYSUAF password MANAGER), non-interactive, +# feeding one DCL WRITE + LOGOUT on stdin. A landed DCL session echoes the marker. +SSH_ERR=/tmp/ssh-e2e-client.err +RESP=$(timeout 90 sshpass -p MANAGER ssh \ + -p "$HOSTPORT" \ + -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o PreferredAuthentications=password -o PubkeyAuthentication=no \ + -o KbdInteractiveAuthentication=no -o NumberOfPasswordPrompts=1 \ + -o ConnectTimeout=15 \ + SYSTEM@127.0.0.1 2>"$SSH_ERR" < execve, sshd -i), SYSUAF/Purdy auth, LOGINOUT->DCL, bytes over the executive BGn: socket (vms-843a)" +else + bad "no DCL marker in the SSH response -- login did not land in DCL" +fi + +# INV-6 negative-ish sanity: a bad password must NOT land in DCL (auth is real). +BADRESP=$(timeout 90 sshpass -p WRONGPASS ssh \ + -p "$HOSTPORT" \ + -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o PreferredAuthentications=password -o PubkeyAuthentication=no \ + -o KbdInteractiveAuthentication=no -o NumberOfPasswordPrompts=1 \ + -o ConnectTimeout=15 \ + SYSTEM@127.0.0.1 2>/dev/null </dev/null + echo "--- console tail (last 400 lines) ---"; tail -400 "$LOG" 2>/dev/null +fi +echo "=== test_ssh_boot_e2e: $([ "$FAIL" = 0 ] && echo PASS || echo FAIL) ===" +exit "$FAIL" diff --git a/third-party/openssh/build-ssh-harness.sh b/third-party/openssh/build-ssh-harness.sh index 22d5be850..15092d38a 100644 --- a/third-party/openssh/build-ssh-harness.sh +++ b/third-party/openssh/build-ssh-harness.sh @@ -222,6 +222,11 @@ for _s in socket connect bind listen accept accept4 dup dup2 read write close \ getpeername getsockname setsockopt getsockopt shutdown fcntl poll ppoll; do SERVER_WRAP="$SERVER_WRAP -Wl,--wrap=$_s" done +# vms-843a: --wrap=main injects the detached-daemon args (-D -e -f ) when +# VMSSSHD is launched argv-less by OVMX DCL RUN (which cannot pass Unix argv). See +# __wrap_main in ovmx_ssh_wrap.c: it fires only for the argv-less parent listener +# (argc<=1); the re-exec'd privsep siblings always carry argv, so they pass through. +SERVER_WRAP="$SERVER_WRAP -Wl,--wrap=main" # SERVER wrap into GLOBAL LIBS so sshd (listener) AND sshd-session/sshd-auth (which # do the connection I/O on the inherited veneer handle) ALL get the wraps -- every # link rule pulls $(LIBS). ssh is already built (stock, above) and is not a dep, so diff --git a/third-party/openssh/ovmx/ovmx_ssh_wrap.c b/third-party/openssh/ovmx/ovmx_ssh_wrap.c index c037b2f0d..b1b5c5836 100644 --- a/third-party/openssh/ovmx/ovmx_ssh_wrap.c +++ b/third-party/openssh/ovmx/ovmx_ssh_wrap.c @@ -179,6 +179,41 @@ int __wrap_dup(int oldfd) return ovmx_materialize_fd(oldfd); /* a fresh real fd on the connection */ return __real_dup(oldfd); } + +/* vms-843a: default-args injection for the DETACHED SSH daemon on a booted OVMX + * distro. Real OpenVMS runs SSH as a detached daemon (TCPIP$SSH_STARTUP does + * RUN/DETACHED), NOT as an inetd accept-handoff service -- and vms-9cc proved the + * wrapped sshd in `-D` mode (sshd itself bind/listen/accepts over BGn:, the model + * this wrap is built for). But OVMX DCL RUN cannot pass Unix argv (its RUN Image + * qualifiers are oracle-verbatim: /DEBUG, /NODEBUG only), so a VMS-launched + * `RUN/DETACHED SYS$SYSTEM:VMSSSHD.EXE` reaches main() argv-less. When that happens + * (argc <= 1) inject the daemon args: -D (stay in the foreground of the detached + * process -- no double-daemonize), -e (log to stderr, which TCPIP$SSH_STARTUP + * routes to OPA0: so it reaches the console for diagnosis), and -f the overlay + * sshd_config. The privsep siblings sshd-session/sshd-auth are NEVER argv-less + * (sshd always re-execs them WITH argv carrying the privsep descriptors), so this + * fires ONLY for the externally-launched parent listener, never a child; and a + * caller that DOES pass args (the KE-test's explicit `sshd -D -e -f ...`) keeps + * them. Bound by -Wl,--wrap=main in the SERVER tree link (build-ssh-harness.sh). */ +#define OVMX_SSHD_DAEMON_CONFIG "/ovmxsshsrv/etc/sshd_config" +/* OpenSSH's main is int main(int, char **); the C runtime still passes envp in a + * 3rd register, but a 2-arg main (and this 2-arg __real_main alias) ignores it -- + * environ is global. Keep the signature matching sshd.c to avoid any ABI mismatch. */ +extern int __real_main(int argc, char **argv); +int __wrap_main(int argc, char **argv) +{ + if (argc <= 1) { + static char *dargv[6]; + dargv[0] = (argv && argv[0]) ? argv[0] : (char *)"VMSSSHD.EXE"; + dargv[1] = (char *)"-D"; + dargv[2] = (char *)"-e"; + dargv[3] = (char *)"-f"; + dargv[4] = (char *)OVMX_SSHD_DAEMON_CONFIG; + dargv[5] = NULL; + return __real_main(5, dargv); + } + return __real_main(argc, argv); +} #endif /* OVMX_WRAP_SERVER */ ssize_t __wrap_read(int fd, void *buf, size_t n) diff --git a/tools/parity/image_parity.py b/tools/parity/image_parity.py index 095211368..b58d81078 100644 --- a/tools/parity/image_parity.py +++ b/tools/parity/image_parity.py @@ -134,6 +134,7 @@ # keeps its teeth against a real shipped x86_64-only image hidden behind an if. _TEST_OVERLAY_BUILD_ARGS = { "OVMX_TEST_ENABLE_TCPIP", + "OVMX_TEST_ENABLE_SSH", } _TEST_OVERLAY_IF_RE = re.compile( r'^if\s+\[\s+"\$(?:' +