diff --git a/.github/workflows/xcat_test.yml b/.github/workflows/xcat_test.yml index c84ace720..629fc208f 100644 --- a/.github/workflows/xcat_test.yml +++ b/.github/workflows/xcat_test.yml @@ -64,6 +64,9 @@ jobs: - name: Exercise the liteimg ramdisk archive if: ${{ !cancelled() }} run: prove xCAT-test/native/liteimg_ramdisk_archive.t + - name: Exercise the UFTP listener + if: ${{ !cancelled() }} + run: prove xCAT-test/native/uftp_listener.t - name: Run tests if: ${{ !cancelled() }} run: perl github_action_xcat_test.pl diff --git a/docs/source/guides/admin-guides/manage_clusters/common/deployment/uftp_rootimg.rst b/docs/source/guides/admin-guides/manage_clusters/common/deployment/uftp_rootimg.rst new file mode 100644 index 000000000..fcea96c44 --- /dev/null +++ b/docs/source/guides/admin-guides/manage_clusters/common/deployment/uftp_rootimg.rst @@ -0,0 +1,141 @@ +Distribute the root image with UFTP multicast +============================================= + +A stateless node downloads its root image over HTTP while it boots. When many nodes boot at the same time, the image server sends the same image once per node. UFTP multicast sends the image once to all nodes that ask for it during a short batch window. HTTP stays available as the fallback. + +UFTP distribution is optional. It is off unless the ``rootimgtransport`` attribute of the osimage, or ``site.rootimgtransport``, is ``uftp``. Images that use ``http``, the default, boot as before, and the UFTP software is not needed. + +How it works +------------ + +#. ``genimage`` adds the UFTP receiver ``uftpd`` and a fetch helper to the stateless initial ramdisk when the image contains the ``uftp`` package of xcat-dep built for the image architecture. It checks that the ramdisk it built holds them, and records that the ramdisk can receive the root image over UFTP. Another ``uftp`` build or architecture in the image gets a warning and is left out. +#. ``packimage`` publishes the archive with its SHA-256 checksum file, as for every image. +#. For an image whose transport is ``uftp``, ``nodeset`` adds ``xcat.uftp=`` and ``xcat.uftpfp=`` to the kernel command line, next to ``imgurl=`` and ``rootimgchecksum=yes``. +#. The booting node downloads the checksum file of ``imgurl``. It asks ``xcat-uftp-listener`` on the image server for the archive that the checksum file lists, then starts its receiver in a private directory. +#. The listener collects the requests for the same archive for ``batch_delay`` seconds, then sends it to all of those nodes in one encrypted UFTP session. It drops a node that misses nearly all the data of a part of the image, so that node cannot delay the others. +#. The node checks the received archive against the checksum file, as it checks an HTTP download. +#. If any step fails, the node stops its receiver, removes its partial files, and downloads ``imgurl`` over HTTP. The node never extracts an archive that does not match the checksum file. + +Supported configurations +------------------------ + +* Stateless images built by ``genimage`` for: + + * the Red Hat family (RHEL, AlmaLinux, Rocky Linux, Oracle Linux, CentOS) with dracut 033 or later, + * openEuler 20.03, 22.03 and 24.03 LTS, + * SLES 15 and openSUSE Leap 15 with dracut 033 or later, + * Ubuntu with dracut. + +* All ``packimage`` formats: ``cpio`` and ``tar`` with ``gzip``, ``pigz`` or ``xz``, and ``squashfs``. +* An IPv4 provisioning network that carries multicast between the image server and the nodes. +* ``site.httpmethod=http``. +* The ``uftp`` package of xcat-dep on the image server and in the image. It provides UFTP 5.0.4 with fixes to the sender and the receiver for Enterprise Linux, openEuler, SLES 15 SP6 or later, openSUSE Leap 15.6, and Ubuntu. Its SLES build needs glibc 2.34, which SLES 15 SP5 and older do not have. + +``nodeset`` refuses a node of a ``uftp`` image, and keeps its current boot entry, in these cases: + +* The root image checksum is turned off by ``rootimgchecksum``. +* ``site.httpmethod`` is ``https``. The node never sends a request meant for HTTPS over plain HTTP. +* The image was packed with ``packimage --tracker`` (BitTorrent). +* The initial ramdisk that nodes boot is not the one that ``genimage`` built with the UFTP receiver. +* The ``rootimgdir`` of the image is outside the ``image_roots`` of the listener, or has characters other than letters, digits and ``._+-`` in its path. +* ``linuximage.addkcmdline`` or ``bootparams.addkcmdline`` sets ``xcat.uftp`` or ``xcat.uftpfp``. ``nodeset`` generates them. + +StateLite images ignore ``rootimgtransport``. Other UFTP builds, UFTP 4.x included, are not supported. ``genimage`` leaves their receiver out of the initial ramdisk, and the listener does not start with their sender. The ``uftp`` packages that Ubuntu ships are UFTP 4.10. + +Security +-------- + +The kernel command line is the trust anchor. It reaches the node through the same TFTP or HTTP path as the kernel and the initial ramdisk. Anyone who can change those files can change the image that a node boots, with or without UFTP. + +* The listener sends only an archive that ``packimage`` published with its checksum file in a configured image root. A request names the image path and a digest that the checksum file must list. Paths are checked after symbolic links are resolved. The image directory, the archive and its checksum file must be owned by root and not writable by other users. The listener opens the archive once and keeps it open until the transfer, so a later ``packimage`` cannot change the bytes of a queued transfer. +* No value from a request reaches the ``uftp`` command line. The listener runs ``uftp`` with an argument vector built from its own configuration. +* UFTP sessions are always encrypted. The listener signs them with ``/etc/xcat/uftp/server.key``. Nodes accept only encrypted sessions from a server with the fingerprint in ``xcat.uftpfp``. +* The nodes of a transfer share its group key, and the server key signs only the messages that set up the session. A node of the batch can therefore send the other nodes file names and file data as if it were the server. The receiver of xcat-dep keeps those files below its staging directory, and the node checks the archive against the checksum file. Such a node can make the other nodes download the image over HTTP. +* The listener refuses a ``uftp`` sender without the build mark of xcat-dep. Stock UFTP 5.0.4 can write past its buffers when a node sends a malformed status message. +* The checksum file protects the integrity of the archive against corruption and against changes on the network, for UFTP and HTTP alike. It does not give confidentiality, and it comes from the same image server as the archive. +* The HTTP fallback has the same confidentiality as an image download without UFTP: none. The image contains the encrypted root password and the files that you added to it. +* The listener accepts requests from any address that can reach ``listen_address``. Set ``allowed_networks`` to limit it to the provisioning networks. + +Install UFTP +------------ + +#. Install the ``uftp`` package from xcat-dep on the management node and on every service node that serves images, for example: :: + + dnf install uftp + +#. Add ``uftp`` from xcat-dep to the image, for example as an other package of the osimage. The package must be built for the image architecture. See :doc:`Install Additional Other Packages`. + +Configure the listener +---------------------- + +The listener is installed with ``xCAT-server`` but is not enabled. Removing ``xCAT-server`` stops and disables it. After an upgrade of ``xCAT-server``, restart a running listener to load the new version. + +#. Create the configuration from the sample, and set at least ``listen_address`` to the address of the image server on the provisioning network: :: + + mkdir -p /etc/xcat/uftp + cp /opt/xcat/share/xcat/conf/uftp-listener.conf.sample /etc/xcat/uftp/listener.conf + + Set ``image_roots`` if an image is outside ``/install/netboot``. Review ``rate_kbps``, ``block_size``, ``batch_delay`` and ``transfer_timeout`` for your network. See `Transfer rate and jumbo frames`_. + +#. Create the server key and its fingerprint: :: + + /opt/xcat/sbin/xcat-uftp-listener --init-key + + ``nodeset`` reads the listener port from ``/etc/xcat/uftp/listener.conf``, and the fingerprint from ``/etc/xcat/uftp/server.fingerprint``. + +#. Check the configuration, then start the service: :: + + /opt/xcat/sbin/xcat-uftp-listener --check + systemctl enable --now xcat-uftp-listener + +#. Allow the traffic in the firewall of the image server: TCP ``listen_port`` (default 1045) from the nodes, outgoing UDP to ``public_multicast`` and ``private_multicast`` on ``udp_port`` (default 1044), and incoming UDP from the nodes. The nodes answer with unicast UDP to the random port that ``uftp`` sends from, and a stateful firewall does not relate those answers to the multicast traffic. + +#. Make sure that the switches forward the multicast groups. With IGMP snooping enabled, the network needs an IGMP querier. + +Each image server runs its own listener, which sends only the images in its own image roots. On a service node that serves images, copy ``/etc/xcat/uftp/listener.conf``, ``server.key`` and ``server.fingerprint`` from the management node. Then set ``listen_address``, and ``multicast_interface`` if it is set, to the address of the service node on its provisioning network, and start the service. ``nodeset`` can run on the management node for those nodes, so every image server must use the same listener port and key. + +The listener writes its messages to the system journal (``journalctl -u xcat-uftp-listener``), and one log file for each transfer to ``/var/log/xcat-uftp``. The service can write only to that directory. To set another ``log_dir``, add it with ``ReadWritePaths=`` in a systemd drop-in for ``xcat-uftp-listener.service``. + +Transfer rate and jumbo frames +------------------------------ + +Set ``rate_kbps`` to no more than the link speed of the slowest node. A node on a slower link loses the packets that do not fit, and the transfer repeats them for all nodes. The default of 100000 (100 Mb/s) works on any network, but it needs about 52 seconds for a 650 MB image. + +One ``uftp`` process sends each transfer on one CPU core, and it handles every packet separately. With the default ``block_size`` of 1300 bytes, a 2.1 GHz Intel Xeon E5 v4 core sends about 1.3 Gb/s, whatever the link speed. A ``block_size`` of 8800 bytes lets the same core send about 3.5 Gb/s. To use it: + +#. Set an MTU of 9000 or more on the listener interface and on the switch ports of the provisioning network. +#. Set the MTU of the provisioning network, so that DHCP gives it to the initial ramdisk of the nodes: :: + + chdef -t network mtu=9000 + makedhcp -n + +#. Set ``block_size = 8800`` in ``/etc/xcat/uftp/listener.conf`` and restart ``xcat-uftp-listener``. + +A node that boots with a smaller MTU receives no data. It leaves the transfer and downloads the image over HTTP. + +Build, pack and boot +-------------------- + +#. Generate the image. ``genimage`` reports ``The initial ramdisk ... can receive the root image over UFTP.`` when the receiver is in the initial ramdisk: :: + + genimage + + An existing image that gets the ``uftp`` package later needs ``genimage`` again to rebuild its initial ramdisk. + +#. Pack the image, and set its transport: :: + + packimage + chdef -t osimage rootimgtransport=uftp + + To use UFTP for every stateless image that does not set the attribute, set ``site.rootimgtransport`` instead: :: + + chdef -t site rootimgtransport=uftp + +#. Write the boot settings, then boot the nodes: :: + + nodeset osimage= + rpower boot + +After a ``packimage``, the nodes receive the new archive at their next boot without a new ``nodeset``. Run ``nodeset`` after every ``genimage``. When the initial ramdisk of a ``uftp`` image lacks the receiver, ``nodeset`` refuses its nodes. Install the ``uftp`` package in the image and run ``genimage`` again, or set ``rootimgtransport=http`` for the image. + +To stop using UFTP for an image, set its ``rootimgtransport`` to ``http`` and run ``nodeset`` for its nodes. diff --git a/docs/source/guides/admin-guides/manage_clusters/ppc64le/diskless/customize_image/index.rst b/docs/source/guides/admin-guides/manage_clusters/ppc64le/diskless/customize_image/index.rst index 96a148866..e5cd38981 100644 --- a/docs/source/guides/admin-guides/manage_clusters/ppc64le/diskless/customize_image/index.rst +++ b/docs/source/guides/admin-guides/manage_clusters/ppc64le/diskless/customize_image/index.rst @@ -14,5 +14,6 @@ Optional means all the subitems in this page are not necessary to finish an OS d enable_kdump.rst install_new_kernel.rst acc_initrd_rootimg_gen_ppc64le.rst + uftp_rootimg.rst trim_diskless_rootimg.rst localdisk.rst diff --git a/docs/source/guides/admin-guides/manage_clusters/ppc64le/diskless/customize_image/uftp_rootimg.rst b/docs/source/guides/admin-guides/manage_clusters/ppc64le/diskless/customize_image/uftp_rootimg.rst new file mode 100644 index 000000000..2f0950847 --- /dev/null +++ b/docs/source/guides/admin-guides/manage_clusters/ppc64le/diskless/customize_image/uftp_rootimg.rst @@ -0,0 +1 @@ +.. include:: ../../../common/deployment/uftp_rootimg.rst diff --git a/docs/source/guides/admin-guides/references/man5/linuximage.5.rst b/docs/source/guides/admin-guides/references/man5/linuximage.5.rst index c194c90d2..3cea88b48 100644 --- a/docs/source/guides/admin-guides/references/man5/linuximage.5.rst +++ b/docs/source/guides/admin-guides/references/man5/linuximage.5.rst @@ -19,7 +19,7 @@ SYNOPSIS ******** -\ **linuximage Attributes:**\ \ *imagename*\ , \ *template*\ , \ *boottarget*\ , \ *addkcmdline*\ , \ *pkglist*\ , \ *pkgdir*\ , \ *otherpkglist*\ , \ *otherpkgdir*\ , \ *exlist*\ , \ *postinstall*\ , \ *rootimgdir*\ , \ *kerneldir*\ , \ *nodebootif*\ , \ *otherifce*\ , \ *netdrivers*\ , \ *kernelver*\ , \ *krpmver*\ , \ *permission*\ , \ *dump*\ , \ *crashkernelsize*\ , \ *partitionfile*\ , \ *driverupdatesrc*\ , \ *gpgcheck*\ , \ *rootimgchecksum*\ , \ *comments*\ , \ *disable*\ +\ **linuximage Attributes:**\ \ *imagename*\ , \ *template*\ , \ *boottarget*\ , \ *addkcmdline*\ , \ *pkglist*\ , \ *pkgdir*\ , \ *otherpkglist*\ , \ *otherpkgdir*\ , \ *exlist*\ , \ *postinstall*\ , \ *rootimgdir*\ , \ *kerneldir*\ , \ *nodebootif*\ , \ *otherifce*\ , \ *netdrivers*\ , \ *kernelver*\ , \ *krpmver*\ , \ *permission*\ , \ *dump*\ , \ *crashkernelsize*\ , \ *partitionfile*\ , \ *driverupdatesrc*\ , \ *gpgcheck*\ , \ *rootimgchecksum*\ , \ *rootimgtransport*\ , \ *comments*\ , \ *disable*\ *********** @@ -201,6 +201,12 @@ linuximage Attributes: +\ **rootimgtransport**\ + + Stateless images only. How nodes get the compressed root image at boot. 'http' downloads it from the image server. 'uftp' receives it over UFTP multicast from xcat-uftp-listener, and downloads it over HTTP when that fails. If not set, the default is the value of site.rootimgtransport, which defaults to 'http'. 'uftp' needs the root image checksum (rootimgchecksum), an initial ramdisk that genimage built with the UFTP receiver, and site.httpmethod 'http'. + + + \ **comments**\ Any user-written notes. diff --git a/docs/source/guides/admin-guides/references/man5/site.5.rst b/docs/source/guides/admin-guides/references/man5/site.5.rst index 6b1df0f84..caf146b30 100644 --- a/docs/source/guides/admin-guides/references/man5/site.5.rst +++ b/docs/source/guides/admin-guides/references/man5/site.5.rst @@ -336,6 +336,12 @@ site Attributes: The first upgrade to a release with the check sets that attribute to no for the images that exist then. + rootimgtransport: (http or uftp). Default is http. How the nodes of a stateless + image get the compressed root image at boot. uftp receives it + over UFTP multicast from xcat-uftp-listener, with HTTP as the + fallback. The rootimgtransport attribute of an osimage + overrides this value. + runbootscripts: If set to 'yes' the scripts listed in the postbootscripts attribute in the osimage and postscripts tables will be run during each reboot of stateful (diskful) nodes. This attribute has no diff --git a/docs/source/guides/admin-guides/references/man7/osimage.7.rst b/docs/source/guides/admin-guides/references/man7/osimage.7.rst index 887001d95..daf13b606 100644 --- a/docs/source/guides/admin-guides/references/man7/osimage.7.rst +++ b/docs/source/guides/admin-guides/references/man7/osimage.7.rst @@ -19,7 +19,7 @@ SYNOPSIS ******** -\ **osimage Attributes:**\ \ *addkcmdline*\ , \ *boottarget*\ , \ *bosinst_data*\ , \ *cfmdir*\ , \ *configdump*\ , \ *crashkernelsize*\ , \ *description*\ , \ *driverupdatesrc*\ , \ *dump*\ , \ *environvar*\ , \ *exlist*\ , \ *fb_script*\ , \ *gpgcheck*\ , \ *groups*\ , \ *home*\ , \ *image_data*\ , \ *imagename*\ , \ *imagetype*\ , \ *installp_bundle*\ , \ *installto*\ , \ *isdeletable*\ , \ *kerneldir*\ , \ *kernelver*\ , \ *kitcomponents*\ , \ *krpmver*\ , \ *lpp_source*\ , \ *mksysb*\ , \ *netdrivers*\ , \ *nfsrootopts*\ , \ *nimmethod*\ , \ *nimtype*\ , \ *nodebootif*\ , \ *osarch*\ , \ *osdistroname*\ , \ *osname*\ , \ *osupdatename*\ , \ *osvers*\ , \ *otherifce*\ , \ *otherpkgdir*\ , \ *otherpkglist*\ , \ *otherpkgs*\ , \ *paging*\ , \ *partitionfile*\ , \ *permission*\ , \ *pkgdir*\ , \ *pkglist*\ , \ *postbootscripts*\ , \ *postinstall*\ , \ *postscripts*\ , \ *profile*\ , \ *provmethod*\ , \ *resolv_conf*\ , \ *root*\ , \ *rootfstype*\ , \ *rootimgchecksum*\ , \ *rootimgdir*\ , \ *script*\ , \ *serverrole*\ , \ *shared_home*\ , \ *shared_root*\ , \ *spot*\ , \ *synclists*\ , \ *template*\ , \ *tmp*\ , \ *usercomment*\ , \ *winpepath* +\ **osimage Attributes:**\ \ *addkcmdline*\ , \ *boottarget*\ , \ *bosinst_data*\ , \ *cfmdir*\ , \ *configdump*\ , \ *crashkernelsize*\ , \ *description*\ , \ *driverupdatesrc*\ , \ *dump*\ , \ *environvar*\ , \ *exlist*\ , \ *fb_script*\ , \ *gpgcheck*\ , \ *groups*\ , \ *home*\ , \ *image_data*\ , \ *imagename*\ , \ *imagetype*\ , \ *installp_bundle*\ , \ *installto*\ , \ *isdeletable*\ , \ *kerneldir*\ , \ *kernelver*\ , \ *kitcomponents*\ , \ *krpmver*\ , \ *lpp_source*\ , \ *mksysb*\ , \ *netdrivers*\ , \ *nfsrootopts*\ , \ *nimmethod*\ , \ *nimtype*\ , \ *nodebootif*\ , \ *osarch*\ , \ *osdistroname*\ , \ *osname*\ , \ *osupdatename*\ , \ *osvers*\ , \ *otherifce*\ , \ *otherpkgdir*\ , \ *otherpkglist*\ , \ *otherpkgs*\ , \ *paging*\ , \ *partitionfile*\ , \ *permission*\ , \ *pkgdir*\ , \ *pkglist*\ , \ *postbootscripts*\ , \ *postinstall*\ , \ *postscripts*\ , \ *profile*\ , \ *provmethod*\ , \ *resolv_conf*\ , \ *root*\ , \ *rootfstype*\ , \ *rootimgchecksum*\ , \ *rootimgdir*\ , \ *rootimgtransport*\ , \ *script*\ , \ *serverrole*\ , \ *shared_home*\ , \ *shared_root*\ , \ *spot*\ , \ *synclists*\ , \ *template*\ , \ *tmp*\ , \ *usercomment*\ , \ *winpepath* *********** @@ -403,6 +403,12 @@ osimage Attributes: +\ **rootimgtransport**\ (linuximage.rootimgtransport) + + Stateless images only. How nodes get the compressed root image at boot. 'http' downloads it from the image server. 'uftp' receives it over UFTP multicast from xcat-uftp-listener, and downloads it over HTTP when that fails. If not set, the default is the value of site.rootimgtransport, which defaults to 'http'. 'uftp' needs the root image checksum (rootimgchecksum), an initial ramdisk that genimage built with the UFTP receiver, and site.httpmethod 'http'. + + + \ **script**\ (nimimage.script) The name of a NIM script resource. diff --git a/perl-xCAT/xCAT/Schema.pm b/perl-xCAT/xCAT/Schema.pm index e03cf2ffa..33a80f6ab 100644 --- a/perl-xCAT/xCAT/Schema.pm +++ b/perl-xCAT/xCAT/Schema.pm @@ -808,7 +808,7 @@ passed as argument rather than by table value', }, }, linuximage => { - cols => [qw(imagename template boottarget addkcmdline pkglist pkgdir otherpkglist otherpkgdir exlist postinstall rootimgdir kerneldir nodebootif otherifce netdrivers kernelver krpmver permission dump crashkernelsize partitionfile driverupdatesrc gpgcheck rootimgchecksum comments disable)], + cols => [qw(imagename template boottarget addkcmdline pkglist pkgdir otherpkglist otherpkgdir exlist postinstall rootimgdir kerneldir nodebootif otherifce netdrivers kernelver krpmver permission dump crashkernelsize partitionfile driverupdatesrc gpgcheck rootimgchecksum rootimgtransport comments disable)], keys => [qw(imagename)], tablespace => 'XCATTBS32K', table_desc => 'Information about a Linux operating system image that can be used to deploy cluster nodes.', @@ -855,6 +855,7 @@ passed as argument rather than by table value', driverupdatesrc => 'The source of the drivers which need to be loaded during the boot. Two types of driver update source are supported: Driver update disk and Driver rpm package. The value for this attribute should be comma separated sources. Each source should be the format tab:full_path_of_source_file. The tab keyword can be: dud (for Driver update disk) and rpm (for driver rpm). If missing the tab, the rpm format is the default. e.g. dud:/install/dud/dd.img,rpm:/install/rpm/d.rpm', gpgcheck => "Diskless Red Hat family images only. Set to 'yes' or '1' to have genimage verify the signature of every package it installs from the pkgdir, kerneldir and otherpkgdir repositories, as it always does for openEuler images. The trusted keys are the RPM signing keys imported on the node that runs genimage (rpm -q gpg-pubkey), so import the key of every repository first, for example rpm --import /etc/pki/rpm-gpg/RPM-GPG-KEY-EPEL-9. The default is no verification.", rootimgchecksum => "Stateless images and statelite ramdisk images only. Set to 'yes' or '1' to have nodeset tell nodes to boot the compressed root image only after it matches its SHA-256 checksum. Set to 'no' or '0' to have nodes boot it unchecked. packimage, liteimg and imgimport always write the checksum. If not set, the default is the value of site.rootimgchecksum, which defaults to 'yes'. The first upgrade to a release with the check sets this attribute to 'no' for the images that exist then.", + rootimgtransport => "Stateless images only. How nodes get the compressed root image at boot. 'http' downloads it from the image server. 'uftp' receives it over UFTP multicast from xcat-uftp-listener, and downloads it over HTTP when that fails. If not set, the default is the value of site.rootimgtransport, which defaults to 'http'. 'uftp' needs the root image checksum (rootimgchecksum), an initial ramdisk that genimage built with the UFTP receiver, and site.httpmethod 'http'.", comments => 'Any user-written notes.', disable => "Set to 'yes' or '1' to comment out this row.", }, @@ -1235,6 +1236,11 @@ passed as argument rather than by table value', " rootimgchecksum attribute of an osimage overrides this value.\n" . " The first upgrade to a release with the check sets that\n" . " attribute to no for the images that exist then.\n\n" . +" rootimgtransport: (http or uftp). Default is http. How the nodes of a stateless\n" . +" image get the compressed root image at boot. uftp receives it\n" . +" over UFTP multicast from xcat-uftp-listener, with HTTP as the\n" . +" fallback. The rootimgtransport attribute of an osimage\n" . +" overrides this value.\n\n" . " runbootscripts: If set to 'yes' the scripts listed in the postbootscripts\n" . " attribute in the osimage and postscripts tables will be run during\n" . " each reboot of stateful (diskful) nodes. This attribute has no\n" . @@ -3333,6 +3339,11 @@ push(@{ $defspec{node}->{'attrs'} }, @nodeattrs); tabentry => 'linuximage.rootimgchecksum', access_tabentry => 'linuximage.imagename=attr:imagename', }, + { attr_name => 'rootimgtransport', + only_if => 'imagetype=linux', + tabentry => 'linuximage.rootimgtransport', + access_tabentry => 'linuximage.imagename=attr:imagename', + }, { attr_name => 'exlist', only_if => 'imagetype=linux', tabentry => 'linuximage.exlist', diff --git a/xCAT-server/debian/install b/xCAT-server/debian/install index 103003e55..822b965cc 100644 --- a/xCAT-server/debian/install +++ b/xCAT-server/debian/install @@ -32,6 +32,7 @@ etc/init.d/xcatd opt/xcat/share/xcat/scripts debian/xcatd.md5sum opt/xcat/share/xcat/scripts debian/xcatd-init-state opt/xcat/share/xcat/scripts etc/init.d/xcatd.service lib/systemd/system/ +etc/init.d/xcat-uftp-listener.service lib/systemd/system/ xCAT-wsapi/* opt/xcat/ws/ LICENSE.html opt/xcat/share/doc/packages/xCAT-server diff --git a/xCAT-server/debian/prerm b/xCAT-server/debian/prerm index c4adbfda6..d0b466a4f 100644 --- a/xCAT-server/debian/prerm +++ b/xCAT-server/debian/prerm @@ -24,6 +24,7 @@ case "$1" in if [ -f "/proc/cmdline" ]; then # prevent running it during install into chroot image if "$xcatd_init_compat" can-use-systemctl; then systemctl stop xcatd.service + systemctl disable --now xcat-uftp-listener.service >/dev/null 2>&1 || true elif ! "$xcatd_init_compat" uses-systemd --explicit-target && [ -x /etc/init.d/xcatd ]; then /etc/init.d/xcatd stop fi diff --git a/xCAT-server/etc/init.d/xcat-uftp-listener.service b/xCAT-server/etc/init.d/xcat-uftp-listener.service new file mode 100644 index 000000000..ffdfa71f0 --- /dev/null +++ b/xCAT-server/etc/init.d/xcat-uftp-listener.service @@ -0,0 +1,28 @@ +[Unit] +Description=xCAT UFTP multicast image listener +After=network-online.target +Wants=network-online.target +ConditionPathExists=/etc/xcat/uftp/listener.conf + +[Service] +Type=simple +ExecStartPre=/opt/xcat/sbin/xcat-uftp-listener --check +ExecStart=/opt/xcat/sbin/xcat-uftp-listener +Restart=on-failure +RestartSec=10s +KillMode=mixed +TimeoutStopSec=15 +LimitNOFILE=4096 +CapabilityBoundingSet= +NoNewPrivileges=yes +PrivateTmp=yes +ProtectHome=yes +ProtectSystem=strict +LogsDirectory=xcat-uftp +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK + +[Install] +WantedBy=multi-user.target diff --git a/xCAT-server/lib/perl/xCAT/SvrUtils.pm b/xCAT-server/lib/perl/xCAT/SvrUtils.pm index eb7e8987a..0fe4f6323 100644 --- a/xCAT-server/lib/perl/xCAT/SvrUtils.pm +++ b/xCAT-server/lib/perl/xCAT/SvrUtils.pm @@ -2690,6 +2690,28 @@ sub rootimg_checksum_enabled { return !(defined($value) && $value =~ /^\s*(?:no|0)\s*$/ix); } +#-------------------------------------------------------------------------------------------------------- +#rootimg_transport: +#description: tell how the nodes of an image get the compressed rootimage at boot +#argument: +# $value: the rootimgtransport attribute of the image, or undef +#return: +# 'http' or 'uftp', from the image or, if the image does not set it, from site.rootimgtransport, +# or undef and the error message for another value. Call it in list context. +#-------------------------------------------------------------------------------------------------------- +sub rootimg_transport { + my (undef, $value) = @_; + my $source = 'The rootimgtransport attribute of the image'; + if (!defined($value) || $value !~ /\S/x) { + ($value) = xCAT::TableUtils->get_site_attribute('rootimgtransport'); + $source = 'site.rootimgtransport'; + } + return 'http' if !defined($value) || $value !~ /\S/x; + my $transport = lc($value =~ s/^\s+|\s+$//grx); + return $transport if $transport eq 'http' || $transport eq 'uftp'; + return (undef, "$source is '$value', but only http and uftp are supported."); +} + #-------------------------------------------------------------------------------------------------------- #upgrade_rootimg_checksum: #description: on an upgrade from a release without the root image check, keep the stateless and statelite diff --git a/xCAT-server/lib/perl/xCAT/UFTP.pm b/xCAT-server/lib/perl/xCAT/UFTP.pm new file mode 100644 index 000000000..bb8979fa7 --- /dev/null +++ b/xCAT-server/lib/perl/xCAT/UFTP.pm @@ -0,0 +1,890 @@ +package xCAT::UFTP; + +=head1 NAME + +xCAT::UFTP - validation and request helpers for UFTP root image distribution + +=head1 DESCRIPTION + +genimage records which initial ramdisk carries the UFTP receiver. nodeset +turns that record and the listener configuration into kernel arguments. +xcat-uftp-listener sends a root image that packimage published with its +SHA-256 checksum, and keeps its transfer jobs with the subs in this module. +All of them share the checks in this module. + +=cut + +use strict; +use warnings; + +use Cwd (); +use Digest::SHA (); +use Errno qw(ENOENT); +use Fcntl qw(:mode O_RDONLY O_NOFOLLOW); +use File::Basename (); +use File::Path (); +use IO::Handle (); +use Socket (); + +our $CONFIG_FILE = '/etc/xcat/uftp/listener.conf'; +our $HELPER = 'xcat-rootimg-fetch.sh'; +our $TARGET_PREFIX = '/xcat-uftp/v1'; +our @ARTIFACTS = qw(rootimg.cpio.gz rootimg.cpio.xz rootimg.tar.gz rootimg.tar.xz rootimg.sfs); +our @RECEIVERS = qw(/usr/sbin/uftpd /usr/bin/uftpd); +our @BOOT_KEYS = qw(xcat.uftp xcat.uftpfp); +our $KILL_GRACE = 5; + +my $MAX_PATH = 1024; +my $MAX_SEGMENTS = 64; +my $MAX_REQUEST = 8192; +my $MAX_FINISHED = 1024; +my $SEGMENT = qr/[A-Za-z0-9_+-][A-Za-z0-9._+-]{0,254}/; +my $SHA256 = qr/[0-9a-f]{64}/; +my $FINGERPRINT = qr/(?:[0-9A-F]{2}:){19}[0-9A-F]{2}/; + +my %ELF_MACHINE = ( + x86 => [ 1, 3 ], + x86_64 => [ 2, 62 ], + ppc64 => [ 2, 21, 2 ], + ppc64le => [ 2, 21, 1 ], + ppc64el => [ 2, 21, 1 ], + aarch64 => [ 2, 183 ], + riscv64 => [ 2, 243 ], + s390x => [ 2, 22 ], +); + +my %CONFIG = ( + listen_address => { type => 'ipv4', required => 1 }, + listen_port => { type => 'int', default => 1045, min => 1, max => 65535 }, + allowed_networks => { type => 'networks', default => '' }, + multicast_interface => { type => 'ipv4' }, + image_roots => { type => 'dirs', default => '/install/netboot' }, + udp_port => { type => 'int', default => 1044, min => 1, max => 65535 }, + public_multicast => { type => 'mcast', default => '230.4.4.1' }, + private_multicast => { type => 'mcast_pattern', default => '230.5.5.x' }, + ttl => { type => 'int', default => 1, min => 1, max => 255 }, + rate_kbps => { type => 'int', default => 100000, min => 1, max => 100000000 }, + # uftp accepts block sizes from 512 to 8800 bytes. + block_size => { type => 'int', default => 1300, min => 512, max => 8800 }, + batch_delay => { type => 'int', default => 30, min => 0, max => 3600 }, + transfer_timeout => { type => 'int', default => 1800, min => 1, max => 86400 }, + max_transfers => { type => 'int', default => 4, min => 1, max => 64 }, + max_queued_jobs => { type => 'int', default => 32, min => 1, max => 1024 }, + max_clients_per_job => { type => 'int', default => 1000, min => 1, max => 100000 }, + max_connections => { type => 'int', default => 64, min => 1, max => 1024 }, + request_timeout => { type => 'int', default => 10, min => 1, max => 300 }, + job_retention => { type => 'int', default => 600, min => 0, max => 86400 }, + encryption => { type => 'enum', default => 'aes256-gcm', + values => [qw(aes128-gcm aes256-gcm aes128-ccm aes256-ccm)] }, + key_file => { type => 'abspath', default => '/etc/xcat/uftp/server.key' }, + fingerprint_file => { type => 'abspath', default => '/etc/xcat/uftp/server.fingerprint' }, + uftp => { type => 'abspath', default => '/usr/bin/uftp' }, + uftp_keymgt => { type => 'abspath', default => '/usr/bin/uftp_keymgt' }, + log_dir => { type => 'abspath', default => '/var/log/xcat-uftp' }, +); + +sub artifact_name_ok { + my ($name) = @_; + return defined($name) && grep { $_ eq $name } @ARTIFACTS; +} + +sub sha256_fh { + my ($fh) = @_; + my $sha = Digest::SHA->new(256); + my $size = 0; + while (1) { + my $count = sysread($fh, my $buffer, 1048576); + return (undef, undef, "read failed: $!") unless defined($count); + last if $count == 0; + $size += $count; + $sha->add($buffer); + } + return ($sha->hexdigest, $size, undef); +} + +sub sha256_file { + my ($path) = @_; + sysopen(my $fh, $path, O_RDONLY | O_NOFOLLOW) + or return (undef, undef, "Cannot open $path: $!"); + binmode($fh); + my ($digest, $size, $error) = sha256_fh($fh); + unless (close($fh)) { + $error ||= "close failed: $!"; + } + return (undef, undef, "Cannot read $path: $error") if $error; + return ($digest, $size, undef); +} + +sub _read_text { + my ($path, $follow) = @_; + sysopen(my $fh, $path, $follow ? O_RDONLY : O_RDONLY | O_NOFOLLOW) or return (undef, "$!"); + my $text = ''; + while (1) { + my $count = sysread($fh, my $buffer, 65536); + return (undef, "$!") unless defined($count); + last if $count == 0; + $text .= $buffer; + return (undef, 'file too large') if length($text) > 65536; + } + close($fh) or return (undef, "$!"); + return ($text, undef); +} + +sub _write_atomic { + my ($path, $content, $mode) = @_; + my $temp = File::Basename::dirname($path) . '/.' . File::Basename::basename($path) . ".tmp$$"; + unlink($temp); + sysopen(my $fh, $temp, Fcntl::O_WRONLY() | Fcntl::O_CREAT() | Fcntl::O_EXCL(), 0600) + or return "Cannot create $temp: $!"; + my $fail = sub { + my ($message) = @_; + close($fh); + unlink($temp); + return $message; + }; + my $written = syswrite($fh, $content); + return $fail->("Cannot write $temp: $!") unless defined($written) && $written == length($content); + return $fail->("Cannot flush $temp: $!") unless $fh->sync; + unless (close($fh)) { + my $message = "Cannot close $temp: $!"; + unlink($temp); + return $message; + } + unless (chmod($mode, $temp) && rename($temp, $path)) { + my $message = "Cannot publish $path: $!"; + unlink($temp); + return $message; + } + return undef; +} + +sub write_file_atomic { + my ($path, $content, $mode) = @_; + return _write_atomic($path, $content, $mode); +} + +sub normalize_image_path { + my ($path) = @_; + return undef unless defined($path) && length($path) <= $MAX_PATH; + return undef unless $path =~ m{^/}; + $path =~ s{/+}{/}g; + $path =~ s{(?<=.)/$}{}; + my @segments = split m{/}, substr($path, 1); + return undef unless @segments && @segments <= $MAX_SEGMENTS; + for my $segment (@segments) { + return undef unless $segment =~ /^$SEGMENT$/; + } + return '/' . join('/', @segments); +} + +sub parse_transfer_target { + my ($target) = @_; + my $prefix = quotemeta("$TARGET_PREFIX/transfer/"); + return (undef, undef, 'unknown request target') + unless defined($target) && $target =~ m{^$prefix($SHA256)(/.*)$}; + my ($digest, $path) = ($1, $2); + my $normalized = normalize_image_path($path); + return (undef, undef, 'malformed image path') + unless defined($normalized) && $normalized eq $path; + return (undef, undef, 'unsupported image artifact') + unless artifact_name_ok(File::Basename::basename($path)); + return ($digest, $path, undef); +} + +sub parse_job_target { + my ($target) = @_; + my $prefix = quotemeta("$TARGET_PREFIX/job/"); + return (undef, 'unknown request target') + unless defined($target) && $target =~ m{^$prefix([0-9a-f]{16})$}; + return ($1, undef); +} + +sub _trusted_entry { + my ($path, $want_dir) = @_; + my @st = lstat($path); + return "$path does not exist" unless @st; + return "$path is a symbolic link" if S_ISLNK($st[2]); + return "$path is not a directory" if $want_dir && !S_ISDIR($st[2]); + return "$path is not a regular file" if !$want_dir && !S_ISREG($st[2]); + return "$path is not owned by root" unless $st[4] == 0 || $st[4] == $>; + return "$path is writable by other users" if $st[2] & 022; + return undef; +} + +sub _contained { + my ($path, $root) = @_; + return 1 if $root eq '/'; + return $path eq $root || index($path, "$root/") == 0; +} + +# Returns the lines of a checksum file in sha256sum format as pairs of digest and file name. +sub checksum_lines { + my ($text) = @_; + return map { /^($SHA256) [ *](\S+)$/ ? [ $1, $2 ] : () } split /\n/, $text // ''; +} + +sub resolve_artifact { + my ($roots, $image_path, $digest) = @_; + my $name = File::Basename::basename($image_path); + my $dir = File::Basename::dirname($image_path); + my $real = Cwd::realpath($dir); + return (undef, 'image directory does not exist') unless defined($real) && -d $real; + return (undef, 'image directory is outside the configured image roots') + unless grep { _contained($real, $_) } @$roots; + + my $archive = "$real/$name"; + for my $check ([$real, 1], [$archive, 0], ["$archive.sha256", 0]) { + my $error = _trusted_entry(@$check); + return (undef, "image is not published: $error") if $error; + } + + # packimage publishes the archive with its checksum file, which lists the previous archive too while it replaces it. + my ($sums, $error) = _read_text("$archive.sha256"); + return (undef, "cannot read the checksum of the image: $error") unless defined($sums); + return (undef, 'requested image generation is not the published one') + unless grep { $_->[0] eq $digest && $_->[1] eq $name } checksum_lines($sums); + + my @link = lstat($archive); + sysopen(my $fh, $archive, O_RDONLY | O_NOFOLLOW) + or return (undef, "cannot open published image: $!"); + binmode($fh); + my @st = stat($fh); + unless (@st && @link && S_ISREG($st[2]) && $st[0] == $link[0] && $st[1] == $link[1]) { + close($fh); + return (undef, 'published image changed during validation'); + } + unless ($st[7] > 0) { + close($fh); + return (undef, 'published image is empty'); + } + return ({ + fh => $fh, + dev => $st[0], + ino => $st[1], + size => $st[7], + sha256 => $digest, + name => $name, + path => $archive, + }, undef); +} + +my %REASONS = ( + 200 => 'OK', + 400 => 'Bad Request', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 408 => 'Request Timeout', + 413 => 'Content Too Large', + 500 => 'Internal Server Error', + 503 => 'Service Unavailable', +); + +sub parse_http_request { + my ($buffer) = @_; + my ($end) = grep { $_ >= 0 } sort { $a <=> $b } (index($buffer, "\r\n\r\n"), index($buffer, "\n\n")); + return { incomplete => 1 } unless defined($end); + my @lines = split /\r?\n/, substr($buffer, 0, $end); + my $line = shift(@lines) // ''; + return { status => 400, reason => 'malformed request line' } + unless $line =~ m{^([A-Z]{1,16}) (\S{1,2048}) HTTP/1\.[01]$}; + my ($method, $target) = ($1, $2); + return { status => 400, reason => 'too many header lines' } if @lines > 64; + for my $header (@lines) { + return { status => 400, reason => 'malformed header line' } + unless $header =~ /^[!#\$%&'*+.^_`|~0-9A-Za-z-]{1,64}:[\t\x20-\x7e]*$/; + } + return { status => 405, reason => 'only GET is supported' } unless $method eq 'GET'; + return { status => 400, reason => 'malformed request target' } + unless $target =~ m{^/[A-Za-z0-9._+/-]*$}; + return { method => $method, target => $target }; +} + +sub http_response { + my ($status, $body) = @_; + $body = '' unless defined($body); + my $reason = $REASONS{$status} || 'Error'; + return "HTTP/1.0 $status $reason\r\nContent-Type: text/plain\r\n" + . 'Content-Length: ' . length($body) . "\r\nConnection: close\r\n\r\n$body"; +} + +sub format_record { + my (@pairs) = @_; + my $text = ''; + while (@pairs) { + my ($key, $value) = splice(@pairs, 0, 2); + $text .= "$key=$value\n"; + } + return $text; +} + +sub ipv4_ok { + my ($address) = @_; + return 0 unless defined($address) && $address =~ /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + for my $octet ($1, $2, $3, $4) { + return 0 if $octet > 255 || $octet =~ /^0\d/; + } + return 1; +} + +sub ipv4_number { + my ($address) = @_; + return unpack('N', Socket::inet_aton($address)); +} + +sub address_allowed { + my ($networks, $address) = @_; + return 1 unless $networks && @$networks; + my $number = ipv4_number($address); + for my $network (@$networks) { + return 1 if ($number & $network->[1]) == $network->[0]; + } + return 0; +} + +sub _config_value { + my ($key, $spec, $value) = @_; + my $type = $spec->{type}; + if ($type eq 'int') { + return (undef, "$key must be an integer") unless $value =~ /^(?:0|[1-9][0-9]{0,8})$/; + return (undef, "$key must be between $spec->{min} and $spec->{max}") + if $value < $spec->{min} || $value > $spec->{max}; + return ($value + 0, undef); + } + if ($type eq 'ipv4') { + return (undef, "$key must be an IPv4 address") unless ipv4_ok($value); + return ($value, undef); + } + if ($type eq 'mcast') { + return (undef, "$key must be an IPv4 multicast address") + unless ipv4_ok($value) && $value =~ /^(\d+)\./ && $1 >= 224 && $1 <= 239; + return ($value, undef); + } + if ($type eq 'mcast_pattern') { + (my $probe = $value) =~ s/\bx\b/1/g; + return (undef, "$key must be an IPv4 multicast address pattern") + unless $value =~ /^\d/ && ipv4_ok($probe) && $probe =~ /^(\d+)\./ && $1 >= 224 && $1 <= 239; + return ($value, undef); + } + if ($type eq 'enum') { + return (undef, "$key must be one of " . join(', ', @{ $spec->{values} })) + unless grep { $_ eq $value } @{ $spec->{values} }; + return ($value, undef); + } + if ($type eq 'abspath') { + return (undef, "$key must be an absolute path") unless $value =~ m{^/[^\0\s]*$}; + return ($value, undef); + } + if ($type eq 'dirs') { + my @dirs; + for my $dir (split /\s*,\s*/, $value) { + return (undef, "$key entries must be absolute paths") unless $dir =~ m{^/}; + my $real = Cwd::realpath($dir); + return (undef, "$key entry $dir is not a directory") unless defined($real) && -d $real; + push @dirs, $real; + } + return (undef, "$key must list at least one directory") unless @dirs; + return (\@dirs, undef); + } + if ($type eq 'networks') { + my @networks; + for my $entry (split /\s*,\s*/, $value) { + return (undef, "$key entry $entry must be address/prefix") + unless $entry =~ m{^([^/]+)/(\d{1,2})$} && ipv4_ok($1) && $2 <= 32; + my $mask = $2 ? (0xffffffff << (32 - $2)) & 0xffffffff : 0; + push @networks, [ ipv4_number($1) & $mask, $mask ]; + } + return (\@networks, undef); + } + return (undef, "$key has an unknown type"); +} + +sub load_listener_config { + my ($path) = @_; + $path ||= $CONFIG_FILE; + my ($text, $error) = _read_text($path, 1); + return (undef, "Cannot read $path: $error") unless defined($text); + my %raw; + my $number = 0; + for my $line (split /\n/, $text) { + $number++; + next if $line =~ /^\s*(?:#|$)/; + return (undef, "$path line $number: expected key = value") + unless $line =~ /^\s*([a-z_]+)\s*=\s*(.*?)\s*$/; + my ($key, $value) = ($1, $2); + return (undef, "$path line $number: unknown key '$key'") unless $CONFIG{$key}; + return (undef, "$path line $number: duplicate key '$key'") if exists($raw{$key}); + $raw{$key} = $value; + } + my %config; + for my $key (sort keys %CONFIG) { + my $spec = $CONFIG{$key}; + my $value = ($raw{$key} // '') ne '' ? $raw{$key} : $spec->{default}; + if (!defined($value) || $value eq '' && $spec->{type} ne 'networks') { + return (undef, "$path: $key is required") if $spec->{required}; + next; + } + my ($parsed, $value_error) = _config_value($key, $spec, $value); + return (undef, "$path: $value_error") if $value_error; + $config{$key} = $parsed; + } + $config{multicast_interface} ||= $config{listen_address}; + $config{server_id} = sprintf('0x%08X', ipv4_number($config{multicast_interface})); + return (\%config, undef); +} + +sub parse_fingerprint { + my ($text) = @_; + return undef unless defined($text); + return $1 if $text =~ /(?:^|fingerprint:\s*)($FINGERPRINT)\s*$/m; + return undef; +} + +sub read_fingerprint { + my ($path) = @_; + my ($text, $error) = _read_text($path, 1); + return (undef, "Cannot read UFTP server fingerprint $path: $error") unless defined($text); + my $fingerprint = parse_fingerprint($text); + return (undef, "$path does not hold a UFTP key fingerprint") unless $fingerprint; + return ($fingerprint, undef); +} + +sub uftp_command { + my ($config, $name, $hostlist) = @_; + return ( + $config->{uftp}, + '-I', $config->{multicast_interface}, + '-U', $config->{server_id}, + '-p', $config->{udp_port}, + '-M', $config->{public_multicast}, + '-P', $config->{private_multicast}, + '-t', $config->{ttl}, + '-R', $config->{rate_kbps}, + '-b', $config->{block_size}, + # Abort a receiver that misses nearly every block of a section, so it cannot hold the group open. + # uftp leaves up to 7 NAKs of each section uncounted, so a limit of 99 never aborts one. + '-N', 98, + '-Y', $config->{encryption}, + '-h', 'sha256', + '-k', $config->{key_file}, + '-H', "\@$hostlist", + '-x', 2, + '-l', + '-D', $name, + '/dev/fd/3', + ); +} + +# Returns the fingerprint of the server key, or undef and why the listener cannot send. +# $key_fingerprint returns the fingerprint that uftp_keymgt reports for the key, or undef and an error. +sub check_listener_runtime { + my ($config, $key_fingerprint) = @_; + return (undef, "UFTP sender $config->{uftp} is not executable; install the uftp package") + unless -f $config->{uftp} && -x _; + if (my $version_error = uftp_version_error($config->{uftp})) { + return (undef, $version_error); + } + my @st = stat($config->{key_file}); + return (undef, "UFTP server key $config->{key_file} is missing; run xcat-uftp-listener --init-key") + unless @st; + return (undef, "UFTP server key $config->{key_file} must not be accessible by other users") + if $st[2] & 077; + my ($fingerprint, $error) = $key_fingerprint->(); + return (undef, $error) if $error; + my ($published, $read_error) = read_fingerprint($config->{fingerprint_file}); + return (undef, "$read_error; run xcat-uftp-listener --init-key") if $read_error; + return (undef, "$config->{fingerprint_file} does not match $config->{key_file}; run xcat-uftp-listener --init-key") + unless $published eq $fingerprint; + unless (-d $config->{log_dir}) { + eval { File::Path::make_path($config->{log_dir}, { mode => 0750 }) }; + return (undef, "Cannot create $config->{log_dir}") unless -d $config->{log_dir}; + } + + # The file test operators ignore read-only mounts, such as those of the service sandbox. + my $probe = "$config->{log_dir}/.write-check.$$"; + unlink($probe); + sysopen(my $fh, $probe, Fcntl::O_WRONLY() | Fcntl::O_CREAT() | Fcntl::O_EXCL(), 0600) + or return (undef, "Cannot write to $config->{log_dir}: $!"); + close($fh); + unlink($probe); + return ($fingerprint, undef); +} + +# The listener keeps its transfer jobs in this state. spawn starts a sender for a job and returns its PID, +# or undef and an error. signal sends a signal to a sender. +sub listener_state { + my ($config, %args) = @_; + return { + config => $config, + workdir => $args{workdir}, + log => $args{log} || sub { }, + spawn => $args{spawn}, + signal => $args{signal} || sub { kill($_[0], $_[1]) }, + jobs => {}, + pending => {}, + senders => {}, + }; +} + +sub _job_id { + my ($state) = @_; + my $bytes = ''; + if (open(my $random, '<:raw', '/dev/urandom')) { + read($random, $bytes, 8); + close($random); + } + $bytes = pack('N2', int(rand(2**32)), int(rand(2**32))) unless length($bytes) == 8; + my $id = unpack('H16', $bytes); + return exists($state->{jobs}{$id}) ? _job_id($state) : $id; +} + +sub _jobs_in { + my ($state, @states) = @_; + my %want = map { $_ => 1 } @states; + return grep { $want{ $_->{state} } } values %{ $state->{jobs} }; +} + +sub _request_transfer { + my ($state, $peer, $target, $now) = @_; + my ($config, $log) = @$state{qw(config log)}; + my ($digest, $path, $error) = parse_transfer_target($target); + if ($error) { + $log->('warning', "$peer: rejected request: $error"); + return (400, "error=$error\n"); + } + my ($image, $resolve_error) = resolve_artifact($config->{image_roots}, $path, $digest); + if ($resolve_error) { + $log->('warning', "$peer: rejected $path: $resolve_error"); + return (403, "error=image is not published\n"); + } + my $key = join(':', $image->{dev}, $image->{ino}, $digest, $config->{udp_port}); + my $job = $state->{pending}{$key}; + my $repeated; + if ($job) { + close($image->{fh}); + $repeated = $job->{clients}{$peer}; + unless ($repeated) { + if (keys(%{ $job->{clients} }) >= $config->{max_clients_per_job}) { + $log->('warning', "$peer: job $job->{id} is full"); + return (503, "error=transfer is full\n"); + } + $job->{clients}{$peer} = 1; + } + } else { + if (_jobs_in($state, 'pending', 'running') >= $config->{max_queued_jobs}) { + close($image->{fh}); + $log->('warning', "$peer: too many queued transfers"); + return (503, "error=too many queued transfers\n"); + } + $job = { + %$image, + id => _job_id($state), + key => $key, + clients => { $peer => 1 }, + created => $now, + start_at => $now + $config->{batch_delay}, + state => 'pending', + }; + $state->{pending}{$key} = $job; + $state->{jobs}{ $job->{id} } = $job; + $log->('info', "job $job->{id}: queued $path ($digest) for $config->{batch_delay}s"); + } + $log->('info', $repeated ? "job $job->{id}: $peer asked again" : "job $job->{id}: added $peer"); + return (200, format_record( + status => 'queued', + job => $job->{id}, + file => $job->{name}, + size => $job->{size}, + port => $config->{udp_port}, + mcast => $config->{public_multicast}, + server_id => $config->{server_id}, + server_ip => $config->{multicast_interface}, + client_ip => $peer, + start_in => $job->{start_at} > $now ? $job->{start_at} - $now : 0, + timeout => $config->{transfer_timeout}, + )); +} + +sub _job_status { + my ($state, $peer, $target, $now) = @_; + my ($id, $error) = parse_job_target($target); + return (400, "error=$error\n") if $error; + my $job = $state->{jobs}{$id}; + return (404, "error=unknown job\n") unless $job && $job->{clients}{$peer}; + my @fields = (state => $job->{state}); + push @fields, (start_in => $job->{start_at} > $now ? $job->{start_at} - $now : 0) + if $job->{state} eq 'pending'; + push @fields, (timeout => $state->{config}{transfer_timeout}); + push @fields, (reason => $job->{reason}) if $job->{reason}; + return (200, format_record(@fields)); +} + +# Returns the status and body of the reply to the request that $peer sent in $buffer, or an empty list +# while the request is incomplete. +sub listener_reply { + my ($state, $peer, $buffer, $now) = @_; + return (413, "error=request too large\n") if length($buffer) > $MAX_REQUEST; + my $request = parse_http_request($buffer); + return () if $request->{incomplete}; + return ($request->{status}, "error=$request->{reason}\n") if $request->{status}; + my $target = $request->{target}; + return _request_transfer($state, $peer, $target, $now) if index($target, "$TARGET_PREFIX/transfer/") == 0; + return _job_status($state, $peer, $target, $now) if index($target, "$TARGET_PREFIX/job/") == 0; + return (404, "error=unknown request target\n"); +} + +# Returns the status and body that refuse a new connection from $peer while $open connections are open, +# or an empty list to accept it. +sub connection_refusal { + my ($state, $open, $peer) = @_; + my $config = $state->{config}; + return (503, "error=too many connections\n") if $open >= $config->{max_connections}; + unless (address_allowed($config->{allowed_networks}, $peer)) { + $state->{log}->('warning', "$peer: address is not in allowed_networks"); + return (403, "error=client address is not allowed\n"); + } + return (); +} + +sub _finish_job { + my ($state, $job, $result, $reason, $now) = @_; + close($job->{fh}) if $job->{fh}; + delete $job->{fh}; + my $pending = $state->{pending}; + delete $pending->{ $job->{key} } if $pending->{ $job->{key} } && $pending->{ $job->{key} } == $job; + unlink($job->{hostlist}) if $job->{hostlist}; + $job->{state} = $result; + $job->{reason} = $reason if $reason; + $job->{finished} = $now; + $state->{log}->($result eq 'done' ? 'info' : 'error', "job $job->{id}: $result" . ($reason ? " ($reason)" : '')); +} + +sub _start_job { + my ($state, $job, $now) = @_; + my $config = $state->{config}; + delete $state->{pending}{ $job->{key} }; + my $hostlist = "$state->{workdir}/$job->{id}.hosts"; + my $hosts = join('', map { "$_\n" } sort keys %{ $job->{clients} }); + if (my $error = _write_atomic($hostlist, $hosts, 0600)) { + return _finish_job($state, $job, 'failed', $error, $now); + } + $job->{hostlist} = $hostlist; + my $log = "$config->{log_dir}/job-$job->{id}.log"; + my ($pid, $error) = $state->{spawn}->($job, $log, uftp_command($config, $job->{name}, $hostlist)); + return _finish_job($state, $job, 'failed', $error, $now) unless $pid; + close($job->{fh}); + delete $job->{fh}; + $state->{senders}{$pid} = $job; + $job->{pid} = $pid; + $job->{state} = 'running'; + $job->{started} = $now; + $job->{deadline} = $now + $config->{transfer_timeout}; + $state->{log}->('info', "job $job->{id}: sending $job->{name} to " + . scalar(keys %{ $job->{clients} }) . " client(s), pid $pid, log $log"); +} + +# Starts each pending job whose batch delay has passed while fewer than max_transfers senders run, +# stops senders that exceed transfer_timeout, and forgets finished jobs after job_retention. +sub run_jobs { + my ($state, $now) = @_; + my $config = $state->{config}; + my $running = _jobs_in($state, 'running'); + for my $job (sort { $a->{start_at} <=> $b->{start_at} || $a->{created} <=> $b->{created} } + _jobs_in($state, 'pending')) { + if ($now > $job->{start_at} + $config->{transfer_timeout}) { + _finish_job($state, $job, 'failed', 'no transfer slot became free', $now); + next; + } + next if $job->{start_at} > $now || $running >= $config->{max_transfers}; + _start_job($state, $job, $now); + $running++ if $job->{state} eq 'running'; + } + for my $job (_jobs_in($state, 'running')) { + if (!$job->{timed_out} && $now >= $job->{deadline}) { + $job->{timed_out} = $now; + $state->{signal}->('TERM', $job->{pid}); + } elsif ($job->{timed_out} && $now >= $job->{timed_out} + $KILL_GRACE) { + $state->{signal}->('KILL', $job->{pid}); + } + } + my @finished = sort { $a->{finished} <=> $b->{finished} } _jobs_in($state, 'done', 'failed'); + while (@finished && ($finished[0]{finished} + $config->{job_retention} <= $now + || @finished > $MAX_FINISHED)) { + delete $state->{jobs}{ shift(@finished)->{id} }; + } +} + +# Finishes the job of the sender $pid, which exited with the wait status $status. $reason replaces the +# reason that the status gives. +sub sender_exited { + my ($state, $pid, $status, $now, $reason) = @_; + my $job = delete $state->{senders}{$pid} or return; + unless ($reason) { + if ($job->{timed_out}) { + $reason = "transfer exceeded $state->{config}{transfer_timeout}s"; + } elsif ($status & 127) { + $reason = 'uftp killed by signal ' . ($status & 127); + } elsif ($status >> 8) { + $reason = 'uftp exited with status ' . ($status >> 8); + } + } + _finish_job($state, $job, $reason ? 'failed' : 'done', $reason, $now); +} + +sub sender_pids { + my ($state) = @_; + return keys %{ $state->{senders} }; +} + +# Fails the jobs that still wait for a sender when the listener stops. +sub stop_jobs { + my ($state, $now) = @_; + _finish_job($state, $_, 'failed', 'listener stopped', $now) for _jobs_in($state, 'pending'); +} + +sub image_receiver { + my ($rootimg_dir) = @_; + for my $receiver (@RECEIVERS) { + return $receiver if -f "$rootimg_dir$receiver" && -x _; + } + return undef; +} + +sub elf_arch_error { + my ($path, $arch) = @_; + my $want = $ELF_MACHINE{$arch}; + return "UFTP receivers are not supported for architecture $arch" unless $want; + open(my $fh, '<:raw', $path) or return "Cannot read $path: $!"; + my $count = read($fh, my $header, 20); + close($fh); + return "$path is not an ELF executable" + unless defined($count) && $count == 20 && substr($header, 0, 4) eq "\x7fELF"; + my $class = ord(substr($header, 4, 1)); + my $data = ord(substr($header, 5, 1)); + my $machine = unpack($data == 2 ? 'n' : 'v', substr($header, 18, 2)); + my ($want_class, $want_machine, $want_data) = @$want; + unless ($class == $want_class && $machine == $want_machine + && (!defined($want_data) || $data == $want_data)) { + return "$path was built for another architecture than $arch (ELF class $class, machine $machine)"; + } + return undef; +} + +# The uftp package of xcat-dep marks its build, which carries fixes that stock UFTP 5 lacks. +sub uftp_version_error { + my ($path) = @_; + open(my $fh, '<:raw', $path) or return "Cannot read $path: $!"; + my $content = do { local $/; <$fh> } // ''; + close($fh); + my ($version, $mark) = $content =~ /UFTP version (\d+\.\d+(?:\.\d+)?)( \(xCAT build \d+\))?/; + return "Cannot find the UFTP version in $path" unless defined($version); + return "$path is UFTP $version; UFTP 5.x is required" unless $version =~ /^5\./; + return "$path is UFTP $version without the xCAT fixes; use the uftp package of xcat-dep" unless $mark; + return undef; +} + +sub usable_receiver { + my ($rootimg_dir, $arch) = @_; + my $receiver = image_receiver($rootimg_dir); + return (undef, undef) unless $receiver; + my $error = elf_arch_error("$rootimg_dir$receiver", $arch) + || uftp_version_error("$rootimg_dir$receiver"); + return (undef, "$error; the initial ramdisk will not receive the root image over UFTP") if $error; + return ($receiver, undef); +} + +sub initrd_missing_files { + my ($listing) = @_; + my @paths = map { (split /\s+/, $_)[-1] // '' } @$listing; + my %required = ( + uftpd => qr{^(?:usr/)?s?bin/uftpd$}, + sha256sum => qr{^(?:usr/)?bin/sha256sum$}, + $HELPER => qr{^(?:usr/)?lib/\Q$HELPER\E$}, + ); + return grep { my $re = $required{$_}; !grep { $_ =~ $re } @paths } sort keys %required; +} + +# The marker holds the SHA-256 of the ramdisk, so a replaced ramdisk does not match it. +sub write_initrd_marker { + my ($initrd) = @_; + my ($digest, undef, $error) = sha256_file($initrd); + return $error if $error; + return _write_atomic("$initrd.uftp", "sha256=$digest\n", 0644); +} + +sub remove_initrd_marker { + my ($initrd) = @_; + return undef if unlink("$initrd.uftp") || $! == ENOENT; + return "Cannot remove $initrd.uftp: $!"; +} + +# $initrd can be the copy that nodes boot, and $marker the one genimage wrote next to the ramdisk it built. +sub verify_initrd_marker { + my ($initrd, $marker) = @_; + $marker //= "$initrd.uftp"; + my $hint = 'install the uftp package in the image and run genimage again'; + return "$initrd was not built with the UFTP receiver; $hint" unless -e $marker; + my ($text, $error) = _read_text($marker); + return "Cannot read $marker: $error" unless defined($text); + my ($recorded) = $text =~ /^sha256=($SHA256)\n?$/ or return "$marker is not a UFTP receiver marker"; + my ($digest, undef, $hash_error) = sha256_file($initrd); + return $hash_error if $hash_error; + return "$initrd is not the initial ramdisk that genimage built with the UFTP receiver; $hint" + unless $digest eq $recorded; + return undef; +} + +sub managed_boot_keys { + my ($kcmdline) = @_; + return () unless defined($kcmdline); + my %keys = map { $_ => 1 } @BOOT_KEYS; + my %found; + for my $token (split /\s+/, $kcmdline) { + my ($key) = split /=/, $token, 2; + $found{$key} = 1 if defined($key) && $keys{$key}; + } + return sort keys %found; +} + +# nodeset checks every node, so each version of an initial ramdisk and its marker is hashed once. +my %initrd_marker_errors; + +# Returns the kernel arguments for receiving the root image over UFTP, or undef and why a node cannot. +sub boot_arguments { + my (%args) = @_; + my ($rootimgdir, $artifact) = @args{qw(rootimgdir artifact)}; + return (undef, 'UFTP sends only the compressed root image of a stateless image') if $args{statelite}; + return (undef, "UFTP needs the root image checksum, so set the rootimgchecksum attribute of the image to yes") + unless $args{checksum}; + return (undef, "$rootimgdir has no root image that UFTP can send; run packimage") unless artifact_name_ok($artifact); + return (undef, "$rootimgdir/$artifact has a BitTorrent .metainfo file; UFTP cannot be combined with packimage --tracker") + if -e "$rootimgdir/$artifact.metainfo"; + + my $httpmethod = $args{httpmethod} || 'http'; + return (undef, "UFTP image distribution requires site.httpmethod=http, not $httpmethod") + unless $httpmethod eq 'http'; + + for my $source ([ 'linuximage.addkcmdline', $args{addkcmdline} ], [ 'bootparams.addkcmdline', $args{node_addkcmdline} ]) { + my @managed = managed_boot_keys($source->[1]); + return (undef, "$source->[0] must not set " . join(', ', @managed) . '; nodeset generates them for UFTP images') + if @managed; + } + + my $marker = $args{marker} // "$args{initrd}.uftp"; + my $key = join(':', map { my @stat = stat($_); ($_, @stat ? @stat[ 0, 1, 7, 9 ] : '-') } $args{initrd}, $marker); + my $error = $initrd_marker_errors{$key} //= verify_initrd_marker($args{initrd}, $marker) // ''; + return (undef, $error) if $error; + + my ($config, $config_error) = load_listener_config($args{config}); + return (undef, "UFTP listener configuration: $config_error") if $config_error; + return (undef, "$rootimgdir has a character that a UFTP request cannot carry; use only letters, digits and ._+- in its path") + unless normalize_image_path("$rootimgdir/$artifact"); + my $real = Cwd::realpath($rootimgdir); + return (undef, "$rootimgdir is outside the image_roots of the UFTP listener in " . ($args{config} || $CONFIG_FILE)) + unless defined($real) && grep { _contained($real, $_) } @{ $config->{image_roots} }; + my ($fingerprint, $fingerprint_error) = read_fingerprint($config->{fingerprint_file}); + return (undef, $fingerprint_error) if $fingerprint_error; + return ("xcat.uftp=$config->{listen_port} xcat.uftpfp=$fingerprint ", undef); +} + +1; diff --git a/xCAT-server/lib/xcat/plugins/anaconda.pm b/xCAT-server/lib/xcat/plugins/anaconda.pm index a06a9fe5f..8057490eb 100644 --- a/xCAT-server/lib/xcat/plugins/anaconda.pm +++ b/xCAT-server/lib/xcat/plugins/anaconda.pm @@ -17,6 +17,7 @@ use xCAT::TableUtils; use xCAT::NetworkUtils; use xCAT::MsgUtils; use xCAT::SvrUtils; +use xCAT::UFTP; use xCAT::Yum; use Getopt::Long; @@ -404,6 +405,8 @@ sub mknetboot my $tftpdir; my $cfgpart; my $rootimgchecksum; + my $rootimgtransport; + my $addkcmdline; my $imagename; # set it if running of 'nodeset osimage=xxx' if ($reshash->{$node}->[0] and $reshash->{$node}->[0]->{tftpdir}) { @@ -435,11 +438,12 @@ sub mknetboot if (!$linuximagetab) { $linuximagetab = xCAT::Table->new('linuximage', -create => 1); } - (my $ref1) = $linuximagetab->getAttribs({ imagename => $imagename }, 'rootimgdir', 'nodebootif', 'dump', 'crashkernelsize', 'partitionfile', 'rootimgchecksum'); + (my $ref1) = $linuximagetab->getAttribs({ imagename => $imagename }, 'rootimgdir', 'nodebootif', 'dump', 'crashkernelsize', 'partitionfile', 'rootimgchecksum', 'rootimgtransport', 'addkcmdline'); if (($ref1) && ($ref1->{'rootimgdir'})) { $img_hash{$imagename}->{rootimgdir} = $ref1->{'rootimgdir'}; } $img_hash{$imagename}->{rootimgchecksum} = $ref1->{'rootimgchecksum'} if $ref1; + @{ $img_hash{$imagename} }{qw(rootimgtransport addkcmdline)} = @$ref1{qw(rootimgtransport addkcmdline)} if $ref1; if (($ref1) && ($ref1->{'nodebootif'})) { $img_hash{$imagename}->{nodebootif} = $ref1->{'nodebootif'}; } @@ -497,6 +501,8 @@ sub mknetboot $dump = $ph->{dump}; $cfgpart = $ph->{'cfgpart'}; $rootimgchecksum = $ph->{rootimgchecksum}; + $rootimgtransport = $ph->{rootimgtransport}; + $addkcmdline = $ph->{addkcmdline}; } else { my @ents = xCAT::TableUtils->get_site_attribute("disablenodesetwarning"); @@ -543,11 +549,12 @@ sub mknetboot $linuximagetab = xCAT::Table->new('linuximage'); } if ($linuximagetab) { - (my $ref1) = $linuximagetab->getAttribs({ imagename => $imgname }, 'dump', 'crashkernelsize', 'partitionfile', 'rootimgchecksum'); + (my $ref1) = $linuximagetab->getAttribs({ imagename => $imgname }, 'dump', 'crashkernelsize', 'partitionfile', 'rootimgchecksum', 'rootimgtransport', 'addkcmdline'); if ($ref1 and $ref1->{'dump'}) { $dump = $ref1->{'dump'}; } $rootimgchecksum = $ref1->{'rootimgchecksum'} if $ref1; + ($rootimgtransport, $addkcmdline) = @$ref1{qw(rootimgtransport addkcmdline)} if $ref1; if ($ref1 and $ref1->{'crashkernelsize'}) { $crashkernelsize = $ref1->{'crashkernelsize'}; } @@ -668,6 +675,21 @@ sub mknetboot next; } } + # Nodes boot the initial ramdisk of the image once the copy below runs, or the TFTP copy with --noupdateinitrd. + my $uftp_args = ''; + unless ($statelite) { + my ($transport, $uftp_error) = xCAT::SvrUtils->rootimg_transport($rootimgtransport); + ($uftp_args, $uftp_error) = xCAT::UFTP::boot_arguments(rootimgdir => $rootimgdir, artifact => $compressedrootimg, + checksum => xCAT::SvrUtils->rootimg_checksum_enabled($rootimgchecksum), httpmethod => $httpmethod, + addkcmdline => $addkcmdline, node_addkcmdline => $bootparams->{$node}->[0]->{addkcmdline}, + initrd => ($noupdateinitrd ? $tftppath : $rootimgdir) . "/initrd-stateless.gz", + marker => "$rootimgdir/initrd-stateless.gz.uftp") + if $transport && $transport eq 'uftp'; + if ($uftp_error) { + xCAT::MsgUtils->report_node_error($callback, $node, $uftp_error); + next; + } + } my $copied = 1; if ($docopy && !$noupdateinitrd) { @@ -873,6 +895,7 @@ sub mknetboot $kcmdline = "imgurl=$httpmethod://$imgsrvip:$httpport/$rootimgdir/$compressedrootimg "; } $kcmdline .= $checksum_arg; + $kcmdline .= $uftp_args; $kcmdline .= "XCAT=$xcatmasterip:$xcatdport "; $kcmdline .= "NODE=$node "; diff --git a/xCAT-server/lib/xcat/plugins/debian.pm b/xCAT-server/lib/xcat/plugins/debian.pm index e3c1f5668..1b93307d0 100644 --- a/xCAT-server/lib/xcat/plugins/debian.pm +++ b/xCAT-server/lib/xcat/plugins/debian.pm @@ -12,6 +12,7 @@ use File::Temp qw/tempdir/; use xCAT::Table; use xCAT::Utils; use xCAT::SvrUtils; +use xCAT::UFTP; use xCAT::MsgUtils; use Data::Dumper; use Getopt::Long; @@ -1481,6 +1482,8 @@ sub mknetboot my $nfsrootopts; my $tftpdir; my $rootimgchecksum; + my $rootimgtransport; + my $addkcmdline; my $imagename; # set it if running of 'nodeset osimage=xxx' if ($reshash->{$node}->[0] and $reshash->{$node}->[0]->{tftpdir}) { @@ -1507,11 +1510,12 @@ sub mknetboot if (!$linuximagetab) { $linuximagetab = xCAT::Table->new('linuximage', -create => 1); } - (my $ref1) = $linuximagetab->getAttribs({ imagename => $imagename }, 'rootimgdir', 'rootimgchecksum'); + (my $ref1) = $linuximagetab->getAttribs({ imagename => $imagename }, 'rootimgdir', 'rootimgchecksum', 'rootimgtransport', 'addkcmdline'); if (($ref1) && ($ref1->{'rootimgdir'})) { $img_hash{$imagename}->{rootimgdir} = $ref1->{'rootimgdir'}; } $img_hash{$imagename}->{rootimgchecksum} = $ref1->{'rootimgchecksum'} if $ref1; + @{ $img_hash{$imagename} }{qw(rootimgtransport addkcmdline)} = @$ref1{qw(rootimgtransport addkcmdline)} if $ref1; if (($ref1) && ($ref1->{'nodebootif'})) { $img_hash{$imagename}->{nodebootif} = $ref1->{'nodebootif'}; } @@ -1545,6 +1549,8 @@ sub mknetboot $crashkernelsize = $ph->{crashkernelsize}; $dump = $ph->{dump}; $rootimgchecksum = $ph->{rootimgchecksum}; + $rootimgtransport = $ph->{rootimgtransport}; + $addkcmdline = $ph->{addkcmdline}; } else { # This is deprecated mode to define node's provmethod, not supported now. @@ -1586,11 +1592,12 @@ sub mknetboot $linuximagetab = xCAT::Table->new('linuximage'); } if ($linuximagetab) { - (my $ref1) = $linuximagetab->getAttribs({ imagename => $imgname }, 'dump', 'crashkernelsize', 'rootimgchecksum'); + (my $ref1) = $linuximagetab->getAttribs({ imagename => $imgname }, 'dump', 'crashkernelsize', 'rootimgchecksum', 'rootimgtransport', 'addkcmdline'); if ($ref1 and $ref1->{'dump'}) { $dump = $ref1->{'dump'}; } $rootimgchecksum = $ref1->{'rootimgchecksum'} if $ref1; + ($rootimgtransport, $addkcmdline) = @$ref1{qw(rootimgtransport addkcmdline)} if $ref1; if ($ref1 and $ref1->{'crashkernelsize'}) { $crashkernelsize = $ref1->{'crashkernelsize'}; } @@ -1692,6 +1699,20 @@ sub mknetboot next; } } + # Nodes boot the initial ramdisk of the image once the copy below runs. + my $uftp_args = ''; + unless ($statelite) { + my ($transport, $uftp_error) = xCAT::SvrUtils->rootimg_transport($rootimgtransport); + ($uftp_args, $uftp_error) = xCAT::UFTP::boot_arguments(rootimgdir => $rootimgdir, artifact => $compressedrootimg, + checksum => xCAT::SvrUtils->rootimg_checksum_enabled($rootimgchecksum), httpmethod => 'http', + addkcmdline => $addkcmdline, node_addkcmdline => $bootparams->{$node}->[0]->{addkcmdline}, + initrd => "$rootimgdir/initrd-stateless.gz", marker => "$rootimgdir/initrd-stateless.gz.uftp") + if $transport && $transport eq 'uftp'; + if ($uftp_error) { + xCAT::MsgUtils->report_node_error($callback, $node, $uftp_error); + next; + } + } my $copied = 1; if ($docopy) { @@ -1866,6 +1887,7 @@ sub mknetboot $kcmdline = "imgurl=http://$imgsrv:$httpport/$rootimgdir/$compressedrootimg "; $kcmdline .= $checksum_arg; + $kcmdline .= $uftp_args; $kcmdline .= "XCAT=$xcatmaster:$xcatdport "; } diff --git a/xCAT-server/lib/xcat/plugins/imgport.pm b/xCAT-server/lib/xcat/plugins/imgport.pm index 1ec10b2f3..7c8a0b27f 100644 --- a/xCAT-server/lib/xcat/plugins/imgport.pm +++ b/xCAT-server/lib/xcat/plugins/imgport.pm @@ -641,6 +641,9 @@ sub get_files { # nodeset reads this marker to tell whether the ramdisk checks the root image checksum. $attrs->{ramdiskcheck} = "$ramdisk.rootimgcheck" if -r "$ramdisk.rootimgcheck"; + + # nodeset tells nodes to receive the root image over UFTP only when this marker matches the ramdisk. + $attrs->{ramdiskuftp} = "$ramdisk.uftp" if -r "$ramdisk.uftp"; } unless ($kernel) { @@ -931,7 +934,7 @@ sub make_bundle { # these are the only files we copy in. (unless you have extras) - for my $a ("kernel", "ramdisk", "ramdiskcheck", "rootimg") { + for my $a ("kernel", "ramdisk", "ramdiskcheck", "ramdiskuftp", "rootimg") { my $filenames = $attribs->{$a}; if ($filenames) { my @file_array = split(',', $filenames); @@ -1332,7 +1335,7 @@ sub change_profile { $data->{linuximage}->{rootimgdir} = "$installdir/netboot/" . $data->{osimage}->{osvers} . "/" . $data->{osimage}->{osarch} . "/$new_profile"; } - for my $a ("kernel", "ramdisk", "ramdiskcheck", "rootimg", "rootimgtree", "litefile") { + for my $a ("kernel", "ramdisk", "ramdiskcheck", "ramdiskuftp", "rootimg", "rootimgtree", "litefile") { if ($data->{$a}) { my $fn = basename($data->{$a}); $data->{$a} = $data->{linuximage}->{rootimgdir} . "/$fn"; @@ -1958,7 +1961,7 @@ sub check_import { $callback->({ error => ["Unable to open table '$table'"], errorcode => [1] }); return 0; } - my @missing = grep { !-e "$imgdir/$_" && !-l "$imgdir/$_" } map { basename($_) } map { split(',', $data->{$_} // '') } qw(rootimg kernel ramdisk rootimgtree); + my @missing = grep { !-e "$imgdir/$_" && !-l "$imgdir/$_" } map { basename($_) } map { split(',', $data->{$_} // '') } qw(rootimg kernel ramdisk ramdiskuftp rootimgtree); push @missing, grep { $data->{kit}{$_}{kitdir} && !-d "$imgdir/$_" } sort keys %{ $data->{kit} || {} }; # imgexport keeps an extra directory under its path and an extra file under its name. @@ -2059,7 +2062,7 @@ sub place_boot_files { my ($data, $imgdir, $callback, $placed, $held) = @_; # The root image goes first, so a failed publication leaves the kernel and ramdisk that match the published image. - for my $a ("rootimg", "kernel", "ramdisk", "ramdiskcheck", "rootimgtree", "litefile") { + for my $a ("rootimg", "kernel", "ramdisk", "ramdiskcheck", "ramdiskuftp", "rootimgtree", "litefile") { my $filenames = $data->{$a}; if ($filenames) { my @file_array = split(',', $filenames); diff --git a/xCAT-server/lib/xcat/plugins/sles.pm b/xCAT-server/lib/xcat/plugins/sles.pm index 0e45e1b29..a2b43a9af 100644 --- a/xCAT-server/lib/xcat/plugins/sles.pm +++ b/xCAT-server/lib/xcat/plugins/sles.pm @@ -15,6 +15,7 @@ use xCAT::BootUtils qw(find_genesis_boot_files); use xCAT::TableUtils; use xCAT::NetworkUtils; use xCAT::SvrUtils; +use xCAT::UFTP; use xCAT::MsgUtils; use Data::Dumper; use Getopt::Long; @@ -142,6 +143,8 @@ sub mknetboot my $nfsrootopts; my $cfgpart; my $rootimgchecksum; + my $rootimgtransport; + my $addkcmdline; my $imagename; # set it if running of 'nodeset osimage=xxx' my $ent = $ntents->{$node}->[0]; @@ -162,11 +165,12 @@ sub mknetboot if (!$linuximagetab) { $linuximagetab = xCAT::Table->new('linuximage', -create => 1); } - (my $ref1) = $linuximagetab->getAttribs({ imagename => $imagename }, 'rootimgdir', 'nodebootif', 'dump', 'crashkernelsize', 'partitionfile', 'rootimgchecksum'); + (my $ref1) = $linuximagetab->getAttribs({ imagename => $imagename }, 'rootimgdir', 'nodebootif', 'dump', 'crashkernelsize', 'partitionfile', 'rootimgchecksum', 'rootimgtransport', 'addkcmdline'); if (($ref1) && ($ref1->{'rootimgdir'})) { $img_hash{$imagename}->{rootimgdir} = $ref1->{'rootimgdir'}; } $img_hash{$imagename}->{rootimgchecksum} = $ref1->{'rootimgchecksum'} if $ref1; + @{ $img_hash{$imagename} }{qw(rootimgtransport addkcmdline)} = @$ref1{qw(rootimgtransport addkcmdline)} if $ref1; if (($ref1) && ($ref1->{'nodebootif'})) { $img_hash{$imagename}->{nodebootif} = $ref1->{'nodebootif'}; } @@ -217,6 +221,8 @@ sub mknetboot $crashkernelsize = $ph->{crashkernelsize}; $cfgpart = $ph->{'cfgpart'}; $rootimgchecksum = $ph->{rootimgchecksum}; + $rootimgtransport = $ph->{rootimgtransport}; + $addkcmdline = $ph->{addkcmdline}; $rootimgdir = $ph->{rootimgdir}; unless ($rootimgdir) { @@ -267,11 +273,12 @@ sub mknetboot $linuximagetab = xCAT::Table->new('linuximage'); } if ($linuximagetab) { - (my $ref1) = $linuximagetab->getAttribs({ imagename => $imgname }, 'dump', 'crashkernelsize', 'partitionfile', 'rootimgchecksum'); + (my $ref1) = $linuximagetab->getAttribs({ imagename => $imgname }, 'dump', 'crashkernelsize', 'partitionfile', 'rootimgchecksum', 'rootimgtransport', 'addkcmdline'); if ($ref1 && $ref1->{'dump'}) { $dump = $ref1->{'dump'}; } $rootimgchecksum = $ref1->{'rootimgchecksum'} if $ref1; + ($rootimgtransport, $addkcmdline) = @$ref1{qw(rootimgtransport addkcmdline)} if $ref1; if ($ref1 and $ref1->{'crashkernelsize'}) { $crashkernelsize = $ref1->{'crashkernelsize'}; } @@ -416,6 +423,21 @@ sub mknetboot next; } } + # Nodes boot the initial ramdisk of the image once the copy below runs, or the TFTP copy with --noupdateinitrd. + my $uftp_args = ''; + unless ($statelite) { + my ($transport, $uftp_error) = xCAT::SvrUtils->rootimg_transport($rootimgtransport); + ($uftp_args, $uftp_error) = xCAT::UFTP::boot_arguments(rootimgdir => $rootimgdir, artifact => $compressedrootimg, + checksum => xCAT::SvrUtils->rootimg_checksum_enabled($rootimgchecksum), httpmethod => $httpmethod, + addkcmdline => $addkcmdline, node_addkcmdline => $bootparams->{$node}->[0]->{addkcmdline}, + initrd => ($noupdateinitrd ? $tftppath : $rootimgdir) . "/initrd-stateless.gz", + marker => "$rootimgdir/initrd-stateless.gz.uftp") + if $transport && $transport eq 'uftp'; + if ($uftp_error) { + xCAT::MsgUtils->report_node_error($callback, $node, $uftp_error); + next; + } + } my $copied = 1; if ($docopy && !$noupdateinitrd) { @@ -555,6 +577,7 @@ sub mknetboot $kcmdline = "imgurl=$httpmethod://$imgsrv:$httpport/$rootimgdir/$compressedrootimg "; $kcmdline .= $checksum_arg; + $kcmdline .= $uftp_args; } $kcmdline .= "XCAT=$xcatmaster:$xcatdport "; $kcmdline .= " XCATHTTPPORT=$httpport "; diff --git a/xCAT-server/sbin/xcat-uftp-listener b/xCAT-server/sbin/xcat-uftp-listener new file mode 100755 index 000000000..5a3a68f17 --- /dev/null +++ b/xCAT-server/sbin/xcat-uftp-listener @@ -0,0 +1,283 @@ +#!/usr/bin/env perl +# Accept UFTP transfer requests from booting stateless nodes and multicast +# published root images to them with uftp(1). + +BEGIN +{ + $::XCATROOT = $ENV{'XCATROOT'} ? $ENV{'XCATROOT'} : '/opt/xcat'; +} +use lib "$::XCATROOT/lib/perl"; + +use strict; +use warnings; + +use Errno qw(EAGAIN EINTR EWOULDBLOCK); +use Fcntl qw(F_GETFD F_SETFD FD_CLOEXEC SEEK_SET); +use File::Basename qw(dirname); +use File::Path qw(make_path); +use File::Temp (); +use Getopt::Long qw(GetOptions); +use IO::Select; +use IO::Socket::INET; +use POSIX qw(WNOHANG); +use xCAT::UFTP; + +my $config_path = $xCAT::UFTP::CONFIG_FILE; +my ($init_key, $check_only, $help); +GetOptions( + 'config=s' => \$config_path, + 'init-key' => \$init_key, + 'check' => \$check_only, + 'help|h' => \$help, +) or usage(1); +usage(0) if $help; +usage(1) if @ARGV; + +sub usage { + my ($rc) = @_; + print STDERR "Usage: xcat-uftp-listener [--config FILE] [--init-key | --check]\n"; + exit $rc; +} + +sub logmsg { + my ($level, $message) = @_; + my @t = localtime(); + printf STDERR "%04d-%02d-%02d %02d:%02d:%02d %s: %s\n", + $t[5] + 1900, $t[4] + 1, $t[3], $t[2], $t[1], $t[0], $level, $message; +} + +sub fatal { + my ($message) = @_; + logmsg('error', $message); + exit 1; +} + +my ($config, $config_error) = xCAT::UFTP::load_listener_config($config_path); +fatal($config_error) if $config_error; + +sub key_fingerprint { + # uftp_keymgt prints the key description on standard error. + my $pid = open(my $pipe, '-|'); + return (undef, "Cannot run $config->{uftp_keymgt}: $!") unless defined($pid); + if ($pid == 0) { + open(STDERR, '>&', \*STDOUT); + { exec { $config->{uftp_keymgt} } $config->{uftp_keymgt}, $config->{key_file}; } + POSIX::_exit(127); + } + my $output = do { local $/; <$pipe> } // ''; + close($pipe); + return (undef, "$config->{uftp_keymgt} failed for $config->{key_file}") if $?; + my $fingerprint = xCAT::UFTP::parse_fingerprint($output); + return (undef, "Cannot parse the fingerprint of $config->{key_file}") unless $fingerprint; + return ($fingerprint, undef); +} + +sub init_key { + my $key = $config->{key_file}; + fatal("$config->{uftp_keymgt} is not executable; install the uftp package") + unless -x $config->{uftp_keymgt}; + make_path(dirname($key), { mode => 0755 }) unless -d dirname($key); + unless (-e $key) { + my $old = umask(077); + my $rc = system { $config->{uftp_keymgt} } $config->{uftp_keymgt}, '-g', 'ec:secp256r1', $key; + umask($old); + fatal("Cannot create the UFTP server key $key") if $rc; + } + fatal("Cannot restrict $key: $!") unless chmod(0600, $key); + my ($fingerprint, $error) = key_fingerprint(); + fatal($error) if $error; + if ($error = xCAT::UFTP::write_file_atomic($config->{fingerprint_file}, "$fingerprint\n", 0644)) { + fatal($error); + } + print "UFTP server key: $key\nUFTP server key fingerprint: $fingerprint\n"; + exit 0; +} + +init_key() if $init_key; +my ($fingerprint, $runtime_error) = xCAT::UFTP::check_listener_runtime($config, \&key_fingerprint); +fatal($runtime_error) if $runtime_error; +if ($check_only) { + print "UFTP listener configuration is valid; server key fingerprint $fingerprint\n"; + exit 0; +} + +my $server = IO::Socket::INET->new( + LocalAddr => $config->{listen_address}, + LocalPort => $config->{listen_port}, + Proto => 'tcp', + Listen => 128, + ReuseAddr => 1, +) or fatal("Cannot listen on $config->{listen_address}:$config->{listen_port}: $!"); +$server->blocking(0); + +my $workdir = File::Temp::tempdir('xcat-uftp-XXXXXXXX', TMPDIR => 1, CLEANUP => 1); +chmod(0700, $workdir); + +my %connections; +my $stopping = 0; +$SIG{TERM} = $SIG{INT} = sub { $stopping = 1 }; +$SIG{PIPE} = 'IGNORE'; +$SIG{HUP} = 'IGNORE'; +my $state = xCAT::UFTP::listener_state($config, workdir => $workdir, log => \&logmsg, spawn => \&spawn_sender); + +logmsg('info', "listening on $config->{listen_address}:$config->{listen_port}, " + . "multicast from $config->{multicast_interface} to $config->{public_multicast} port $config->{udp_port}, " + . 'image roots ' . join(',', @{ $config->{image_roots} })); + +sub respond { + my ($conn, $status, $body) = @_; + $conn->{out} = xCAT::UFTP::http_response($status, $body); + $conn->{deadline} = time() + $config->{request_timeout}; +} + +sub close_connection { + my ($conn) = @_; + delete $connections{ fileno($conn->{sock}) }; + close($conn->{sock}); +} + +sub refuse_connection { + my ($sock, $status, $body) = @_; + $sock->blocking(0); + syswrite($sock, xCAT::UFTP::http_response($status, $body)); + close($sock); +} + +sub accept_connections { + my ($now) = @_; + for (1 .. $config->{max_connections}) { + my $sock = $server->accept() or last; + my $peer = $sock->peerhost() // ''; + if (my ($status, $body) = xCAT::UFTP::connection_refusal($state, scalar(keys %connections), $peer)) { + refuse_connection($sock, $status, $body); + next; + } + $sock->blocking(0); + $connections{ fileno($sock) } = { + sock => $sock, + peer => $peer, + buffer => '', + deadline => $now + $config->{request_timeout}, + }; + } +} + +sub read_request { + my ($conn, $now) = @_; + my $count = sysread($conn->{sock}, my $data, 4096); + if (!defined($count)) { + return if $! == EAGAIN || $! == EWOULDBLOCK || $! == EINTR; + return close_connection($conn); + } + if ($count == 0) { + return close_connection($conn); + } + $conn->{buffer} .= $data; + my ($status, $body) = xCAT::UFTP::listener_reply($state, $conn->{peer}, $conn->{buffer}, $now); + respond($conn, $status, $body) if defined($status); +} + +sub write_response { + my ($conn) = @_; + my $count = syswrite($conn->{sock}, $conn->{out}); + if (!defined($count)) { + return if $! == EAGAIN || $! == EWOULDBLOCK || $! == EINTR; + return close_connection($conn); + } + substr($conn->{out}, 0, $count) = ''; + close_connection($conn) unless length($conn->{out}); +} + +sub child_main { + my ($job, $log, @command) = @_; + $SIG{$_} = 'DEFAULT' for qw(TERM INT PIPE HUP); + open(STDIN, '<', '/dev/null'); + open(STDOUT, '>>', $log) or POSIX::_exit(126); + open(STDERR, '>&', \*STDOUT) or POSIX::_exit(126); + my $fh = $job->{fh}; + sysseek($fh, 0, SEEK_SET) or POSIX::_exit(91); + if (fileno($fh) != 3) { + POSIX::dup2(fileno($fh), 3) or POSIX::_exit(92); + } + open(my $image, '<&=', 3) or POSIX::_exit(92); + my $flags = fcntl($image, F_GETFD, 0); + fcntl($image, F_SETFD, $flags & ~FD_CLOEXEC) or POSIX::_exit(92); + print STDERR "starting: @command\n"; + { exec { $command[0] } @command; } + print STDERR "cannot execute $command[0]: $!\n"; + POSIX::_exit(127); +} + +sub spawn_sender { + my ($job, $log, @command) = @_; + my $pid = fork(); + return (undef, "fork failed: $!") unless defined($pid); + child_main($job, $log, @command) if $pid == 0; + return ($pid, undef); +} + +sub reap_senders { + my ($now) = @_; + while ((my $pid = waitpid(-1, WNOHANG)) > 0) { + xCAT::UFTP::sender_exited($state, $pid, $?, $now); + } +} + +sub close_idle_connections { + my ($now) = @_; + for my $conn (values %connections) { + close_connection($conn) if $now >= $conn->{deadline}; + } +} + +sub shutdown_listener { + close($server); + close_connection($_) for values %connections; + my @pids = xCAT::UFTP::sender_pids($state); + kill('TERM', @pids) if @pids; + my $deadline = time() + $xCAT::UFTP::KILL_GRACE; + while (xCAT::UFTP::sender_pids($state) && time() < $deadline) { + reap_senders(time()); + select(undef, undef, undef, 0.1); + } + @pids = xCAT::UFTP::sender_pids($state); + kill('KILL', @pids) if @pids; + while (xCAT::UFTP::sender_pids($state)) { + my $pid = waitpid(-1, 0); + last if $pid <= 0; + xCAT::UFTP::sender_exited($state, $pid, $?, time(), 'listener stopped'); + } + xCAT::UFTP::stop_jobs($state, time()); + logmsg('info', 'stopped'); +} + +while (!$stopping) { + my $now = time(); + reap_senders($now); + xCAT::UFTP::run_jobs($state, $now); + close_idle_connections($now); + my $readers = IO::Select->new($server); + my $writers = IO::Select->new(); + for my $conn (values %connections) { + if (defined($conn->{out})) { + $writers->add($conn->{sock}); + } else { + $readers->add($conn->{sock}); + } + } + my ($readable, $writable) = IO::Select->select($readers, $writers, undef, 1); + $now = time(); + for my $sock (@{ $readable || [] }) { + if ($sock == $server) { + accept_connections($now); + } elsif (my $conn = $connections{ fileno($sock) }) { + read_request($conn, $now); + } + } + for my $sock (@{ $writable || [] }) { + my $conn = $connections{ fileno($sock) // -1 } or next; + write_response($conn) if defined($conn->{out}); + } +} +shutdown_listener(); +exit 0; diff --git a/xCAT-server/share/xcat/conf/uftp-listener.conf.sample b/xCAT-server/share/xcat/conf/uftp-listener.conf.sample new file mode 100644 index 000000000..a644c49e6 --- /dev/null +++ b/xCAT-server/share/xcat/conf/uftp-listener.conf.sample @@ -0,0 +1,72 @@ +# Configuration for xcat-uftp-listener. Copy this file to +# /etc/xcat/uftp/listener.conf, set listen_address, and run +# "xcat-uftp-listener --init-key" once before starting the service. +# Every value is owned by the server; booting nodes cannot override them. + +# IPv4 address of this server on the provisioning network. Nodes reach the +# listener on the same host as their imgurl. Required. +listen_address = 192.0.2.1 + +# TCP port for transfer requests. nodeset passes it to the nodes. +#listen_port = 1045 + +# Comma-separated IPv4 networks allowed to request transfers, for example +# 192.0.2.0/24. Empty allows any address that can reach listen_address. +#allowed_networks = + +# Local IPv4 address that sends the multicast data. Defaults to +# listen_address. It is also the UFTP server ID that nodes accept. +#multicast_interface = + +# Comma-separated directories that may hold published images. An image +# outside these directories is refused even if packimage published it. +#image_roots = /install/netboot + +# UDP port, public announcement address, private data address pattern and +# TTL for the multicast session. +#udp_port = 1044 +#public_multicast = 230.4.4.1 +#private_multicast = 230.5.5.x +#ttl = 1 + +# Transmission rate in kilobits per second. Use no more than the link speed of +# the slowest node. +#rate_kbps = 100000 + +# Data bytes in each multicast packet, from 512 to 8800. The default fits an +# MTU of 1500. Use a larger value only when the listener, the switches and +# every node in the initramfs use an MTU of 9000 or more. A node with a +# smaller MTU receives no data, leaves the transfer and falls back to HTTP. +#block_size = 1300 + +# Seconds to collect requests for the same image before sending. Zero sends +# at once. +#batch_delay = 30 + +# Seconds a transfer may run before it is stopped. Nodes wait for the batch +# delay plus this value before they fall back to HTTP. +#transfer_timeout = 1800 + +# Limits on simultaneous senders, queued transfers, receivers in one +# transfer, open request connections, and seconds to receive a request. +#max_transfers = 4 +#max_queued_jobs = 32 +#max_clients_per_job = 1000 +#max_connections = 64 +#request_timeout = 10 + +# Seconds to keep the result of a finished transfer for status queries. +#job_retention = 600 + +# UFTP session encryption. Nodes accept only encrypted sessions signed by +# the key below. +#encryption = aes256-gcm +#key_file = /etc/xcat/uftp/server.key +#fingerprint_file = /etc/xcat/uftp/server.fingerprint + +# UFTP programs and the directory for one log file per transfer. The +# xcat-uftp-listener service can write only to /var/log/xcat-uftp. For another +# log_dir, add it with ReadWritePaths= in a drop-in for the service. +#uftp = /usr/bin/uftp +#uftp_keymgt = /usr/bin/uftp_keymgt +#log_dir = /var/log/xcat-uftp diff --git a/xCAT-server/share/xcat/netboot/imgutils/imgutils.pm b/xCAT-server/share/xcat/netboot/imgutils/imgutils.pm index 0b8bdf01e..160bc0089 100644 --- a/xCAT-server/share/xcat/netboot/imgutils/imgutils.pm +++ b/xCAT-server/share/xcat/netboot/imgutils/imgutils.pm @@ -141,6 +141,52 @@ sub publish_boot_files { return 1; } +# Puts the UFTP fetch helper in the xCAT dracut module of an initial ramdisk when $wanted is true and the image has the +# UFTP 5 receiver of xcat-dep for its architecture, and removes an older copy otherwise. Returns the receiver or undef. +sub add_uftp_receiver { + my ($rootimg_dir, $arch, $moduledir, $wanted) = @_; + require xCAT::UFTP; + my $copy = "$moduledir/$xCAT::UFTP::HELPER"; + if (-e $copy || -l $copy) { + unlink($copy) or die("Error: cannot remove $copy: $!\n"); + } + return unless $wanted; + my ($receiver, $warning) = xCAT::UFTP::usable_receiver($rootimg_dir, $arch); + print "Warning: $warning\n" if $warning; + return unless $receiver; + my $helper = dirname(__FILE__) . "/$xCAT::UFTP::HELPER"; + File::Copy::copy($helper, $copy) && chmod(0644, $copy) or die("Error: cannot copy $helper to $copy: $!\n"); + print "Adding the UFTP receiver $receiver to the initial ramdisk.\n"; + return $receiver; +} + +# Returns the lsinitrd lines of the initial ramdisk $initrd, a path inside the image, or undef when the image cannot list it. +sub initrd_listing { + my ($rootimg_dir, $initrd) = @_; + my @listing = `chroot $rootimg_dir lsinitrd $initrd 2>&1`; + return $? ? undef : \@listing; +} + +# Dies unless the initial ramdisk $initrd, a path inside the image, holds the UFTP receiver files. +sub check_uftp_initrd { + my ($rootimg_dir, $initrd, $mode) = @_; + require xCAT::UFTP; + my $listing = initrd_listing($rootimg_dir, $initrd); + my @missing = $listing ? xCAT::UFTP::initrd_missing_files($listing) : ('a readable file list'); + die("Error: the initial ramdisk for $mode lacks the UFTP receiver files: @missing\n") if @missing; + return 1; +} + +# Records next to a published initial ramdisk whether it can receive the root image over UFTP. +sub mark_uftp_receiver { + my ($initrd, $receiver) = @_; + require xCAT::UFTP; + my $error = $receiver ? xCAT::UFTP::write_initrd_marker($initrd) : xCAT::UFTP::remove_initrd_marker($initrd); + print "Warning: $error\n" if $error; + print "The initial ramdisk $initrd can receive the root image over UFTP.\n" if $receiver && !$error; + return !$error; +} + sub varsubinline{ my $line=shift; my $refvardict=shift; diff --git a/xCAT-server/share/xcat/netboot/imgutils/xcat-rootimg-fetch.sh b/xCAT-server/share/xcat/netboot/imgutils/xcat-rootimg-fetch.sh new file mode 100644 index 000000000..7cdaea6e6 --- /dev/null +++ b/xCAT-server/share/xcat/netboot/imgutils/xcat-rootimg-fetch.sh @@ -0,0 +1,460 @@ +# Receive a stateless root image over UFTP multicast. xcatroot sources this +# file when nodeset passes xcat.uftp=, and calls xcat_uftp_rootimg. The file +# is not checked here: xcatroot checks it like an HTTP download. + +: "${XCAT_UFTP_ATTEMPTS:=2}" +: "${XCAT_UFTP_GRACE:=30}" +: "${XCAT_UFTP_POLL:=5}" +: "${XCAT_UFTP_REQUEST_TIMEOUT:=15}" +: "${XCAT_UFTP_STOP_WAIT:=10}" + +xcat_fetch_log() { + if command -v logger >/dev/null 2>&1; then + # SYSLOGHOST is either empty or "-n " and must split. + logger $SYSLOGHOST -t "${log_label:-xcat.deployment}" -p "local4.$1" "$2" + fi + echo "$2" >&2 +} + +xcat_uint_ok() { + case "$1" in + 0) return 0 ;; + ''|0*|*[!0-9]*) return 1 ;; + esac + [ "${#1}" -le 9 ] && [ "$1" -le "$2" ] +} + +xcat_port_ok() { + xcat_uint_ok "$1" 65535 && [ "$1" -gt 0 ] +} + +xcat_ipv4_ok() { + case "$1" in + ''|*[!0-9.]*|.*|*.|*..*) return 1 ;; + esac + _xf_ifs=$IFS + IFS=. + set -- $1 + IFS=$_xf_ifs + [ "$#" -eq 4 ] || return 1 + for _xf_octet do + xcat_uint_ok "$_xf_octet" 255 || return 1 + done +} + +xcat_sha256_ok() { + case "$1" in + *[!0-9a-f]*) return 1 ;; + esac + [ "${#1}" -eq 64 ] +} + +xcat_fingerprint_ok() { + case "$1" in + ??:??:??:??:??:??:??:??:??:??:??:??:??:??:??:??:??:??:??:??) ;; + *) return 1 ;; + esac + case "$1" in + *[!0-9A-F:]*) return 1 ;; + esac +} + +# Print the first SHA-256 checksum that the sha256sum file $1 lists for the name $2. +xcat_listed_sha256() { + [ -r "$1" ] || return 1 + while read -r _xf_sum _xf_name || [ -n "$_xf_sum" ]; do + if [ "${_xf_name#\*}" = "$2" ] && xcat_sha256_ok "$_xf_sum"; then + printf '%s\n' "$_xf_sum" + return 0 + fi + done < "$1" + return 1 +} + +# Split an http(s) URL into XCAT_URL_SCHEME, XCAT_URL_HOST (IPv6 keeps its +# brackets), XCAT_URL_PORT (the scheme default when absent) and XCAT_URL_PATH. +xcat_url_parse() { + case "$1" in + http://*) XCAT_URL_SCHEME=http; _xf_rest=${1#http://} ;; + https://*) XCAT_URL_SCHEME=https; _xf_rest=${1#https://} ;; + *) return 1 ;; + esac + case "$_xf_rest" in + */*) _xf_authority=${_xf_rest%%/*}; XCAT_URL_PATH=/${_xf_rest#*/} ;; + *) _xf_authority=$_xf_rest; XCAT_URL_PATH=/ ;; + esac + case "$_xf_authority" in + *@*) return 1 ;; + \[*\]:*) XCAT_URL_HOST="${_xf_authority%%]:*}]"; XCAT_URL_PORT=${_xf_authority##*]:} ;; + \[*\]) XCAT_URL_HOST=$_xf_authority; XCAT_URL_PORT= ;; + *\[*|*\]*|*:*:*) return 1 ;; + *:*) XCAT_URL_HOST=${_xf_authority%:*}; XCAT_URL_PORT=${_xf_authority##*:} ;; + *) XCAT_URL_HOST=$_xf_authority; XCAT_URL_PORT= ;; + esac + case "$XCAT_URL_HOST" in + ''|\[\]) return 1 ;; + \[*\]) + _xf_inner=${XCAT_URL_HOST#\[} + _xf_inner=${_xf_inner%\]} + case "$_xf_inner" in + *[!0-9A-Fa-f:.]*|*:*:*:*:*:*:*:*:*) return 1 ;; + *:*) ;; + *) return 1 ;; + esac + ;; + *[!A-Za-z0-9.-]*|-*|.*) return 1 ;; + esac + if [ -z "$XCAT_URL_PORT" ]; then + if [ "$XCAT_URL_SCHEME" = https ]; then + XCAT_URL_PORT=443 + else + XCAT_URL_PORT=80 + fi + fi + xcat_port_ok "$XCAT_URL_PORT" || return 1 + case "$XCAT_URL_PATH" in + *[?#%]*) return 1 ;; + esac +} + +# Print the image path the listener expects: repeated slashes collapsed and +# every segment limited to the characters packimage accepts. +xcat_image_path() { + _xf_path=$1 + while :; do + case "$_xf_path" in + *//*) _xf_path=${_xf_path%%//*}/${_xf_path#*//} ;; + *) break ;; + esac + done + case "$_xf_path" in + /|*/|[!/]*) return 1 ;; + esac + _xf_ifs=$IFS + IFS=/ + set -f + set -- ${_xf_path#/} + set +f + IFS=$_xf_ifs + for _xf_segment do + case "$_xf_segment" in + ''|.*|*[!A-Za-z0-9._+-]*) return 1 ;; + esac + done + printf '%s\n' "$_xf_path" +} + +# Print the transfer request URL for an image URL: same scheme and host, +# the listener port, and the image path. +xcat_uftp_url() { + xcat_url_parse "$1" || return 1 + _xf_image=$(xcat_image_path "$XCAT_URL_PATH") || return 1 + printf '%s://%s:%s/xcat-uftp/v1/transfer/%s%s\n' \ + "$XCAT_URL_SCHEME" "$XCAT_URL_HOST" "$2" "$3" "$_xf_image" +} + +xcat_http_get() { + if command -v curl >/dev/null 2>&1; then + curl --fail --silent --show-error --max-time "$3" -o "$2" "$1" + elif command -v wget >/dev/null 2>&1; then + # wget -T limits idle time only, so a watchdog limits the whole request. + wget -q -T "$3" -t 1 -O "$2" "$1" & + _xf_get=$! + ( + _xf_left=$3 + while [ "$_xf_left" -gt 0 ]; do + sleep 1 + kill -0 "$_xf_get" 2>/dev/null || exit 0 + _xf_left=$((_xf_left - 1)) + done + kill -KILL "$_xf_get" 2>/dev/null + ) & + _xf_dog=$! + wait "$_xf_get" + _xf_rc=$? + kill "$_xf_dog" 2>/dev/null + wait "$_xf_dog" 2>/dev/null + return "$_xf_rc" + else + return 127 + fi +} + +xcat_uftp_read_ack() { + XCAT_ACK_STATUS='' XCAT_ACK_JOB='' XCAT_ACK_FILE='' XCAT_ACK_PORT='' XCAT_ACK_MCAST='' + XCAT_ACK_SERVER_ID='' XCAT_ACK_SERVER_IP='' XCAT_ACK_CLIENT_IP='' + XCAT_ACK_START_IN='' XCAT_ACK_TIMEOUT='' + while IFS= read -r _xf_line || [ -n "$_xf_line" ]; do + _xf_key=${_xf_line%%=*} + _xf_value=${_xf_line#*=} + [ "$_xf_key" != "$_xf_line" ] || return 1 + case "$_xf_key" in + status) XCAT_ACK_STATUS=$_xf_value ;; + job) XCAT_ACK_JOB=$_xf_value ;; + file) XCAT_ACK_FILE=$_xf_value ;; + port) XCAT_ACK_PORT=$_xf_value ;; + mcast) XCAT_ACK_MCAST=$_xf_value ;; + server_id) XCAT_ACK_SERVER_ID=$_xf_value ;; + server_ip) XCAT_ACK_SERVER_IP=$_xf_value ;; + client_ip) XCAT_ACK_CLIENT_IP=$_xf_value ;; + start_in) XCAT_ACK_START_IN=$_xf_value ;; + timeout) XCAT_ACK_TIMEOUT=$_xf_value ;; + esac + done < "$1" + [ "$XCAT_ACK_STATUS" = queued ] || return 1 + case "$XCAT_ACK_JOB" in + ????????????????) ;; + *) return 1 ;; + esac + case "$XCAT_ACK_JOB" in + *[!0-9a-f]*) return 1 ;; + esac + [ "$XCAT_ACK_FILE" = "$2" ] || return 1 + xcat_port_ok "$XCAT_ACK_PORT" || return 1 + xcat_ipv4_ok "$XCAT_ACK_MCAST" || return 1 + xcat_ipv4_ok "$XCAT_ACK_SERVER_IP" || return 1 + xcat_ipv4_ok "$XCAT_ACK_CLIENT_IP" || return 1 + case "$XCAT_ACK_SERVER_ID" in + 0x????????) ;; + *) return 1 ;; + esac + case "${XCAT_ACK_SERVER_ID#0x}" in + *[!0-9A-F]*) return 1 ;; + esac + : "${XCAT_ACK_START_IN:=0}" + xcat_uint_ok "$XCAT_ACK_START_IN" 3600 || return 1 + xcat_uint_ok "$XCAT_ACK_TIMEOUT" 86400 && [ "$XCAT_ACK_TIMEOUT" -gt 0 ] +} + +# Print a monotonic time in whole seconds. +xcat_now() { + if [ -r /proc/uptime ]; then + read -r _xf_uptime _xf_idle < /proc/uptime + echo "${_xf_uptime%%.*}" + else + date +%s + fi +} + +# Print the last three lines of the file $1. The initramfs has no tail. +xcat_last_lines() { + _xf_l1= _xf_l2= _xf_l3= + while IFS= read -r _xf_line || [ -n "$_xf_line" ]; do + _xf_l1=$_xf_l2 _xf_l2=$_xf_l3 _xf_l3=$_xf_line + done < "$1" + printf '%s %s %s' "$_xf_l1" "$_xf_l2" "$_xf_l3" +} + +xcat_child_running() { + kill -0 "$1" 2>/dev/null || return 1 + if [ -r "/proc/$1/stat" ]; then + read -r _xf_pid _xf_comm _xf_state _xf_rest < "/proc/$1/stat" || return 1 + [ "$_xf_state" != Z ] || return 1 + fi + return 0 +} + +xcat_uftp_start_receiver() { + printf '%s|%s|0|%s\n' "$XCAT_ACK_SERVER_ID" "$XCAT_ACK_SERVER_IP" "$XCAT_FETCH_FP" \ + > "$1/servers" || return 1 + "$XCAT_UFTPD" -d -q -E -x 2 -p "$XCAT_ACK_PORT" -M "$XCAT_ACK_MCAST" \ + -I "$XCAT_ACK_CLIENT_IP" -S "$1/servers" -D "$1/dest" -T "$1/tmp" \ + -F "$1/status" "$1/uftpd.log" 2>&1 & + XCAT_UFTP_PID=$! +} + +xcat_uftp_stop_receiver() { + _xf_receiver=$XCAT_UFTP_PID + [ -n "$_xf_receiver" ] || return 0 + XCAT_UFTP_PID= + kill -TERM "$_xf_receiver" 2>/dev/null + ( + _xf_waited=0 + while [ "$_xf_waited" -lt "$XCAT_UFTP_STOP_WAIT" ]; do + kill -0 "$_xf_receiver" 2>/dev/null || exit 0 + sleep 1 + _xf_waited=$((_xf_waited + 1)) + done + kill -KILL "$_xf_receiver" 2>/dev/null + ) & + _xf_watchdog=$! + wait "$_xf_receiver" 2>/dev/null + wait "$_xf_watchdog" 2>/dev/null + return 0 +} + +xcat_uftp_cleanup() { + xcat_uftp_stop_receiver + if [ -n "$XCAT_FETCH_STAGING" ]; then + rm -rf "$XCAT_FETCH_STAGING" + XCAT_FETCH_STAGING= + fi +} + +# Succeed when this attempt's receiver reported the expected file as copied +# in a session it opened with the expected server, and the file was moved +# out of the temporary directory. +xcat_uftp_completed() { + [ -f "$1/dest/$XCAT_FETCH_FILE" ] && [ -r "$1/status" ] || return 1 + _xf_sid=${XCAT_ACK_SERVER_ID#0x} + _xf_groups= + while IFS=';' read -r _xf_kind _xf_stamp _xf_server _xf_group _xf_name _xf_size _xf_result; do + [ "$_xf_server" = "$_xf_sid" ] || continue + case "$_xf_kind" in + CONNECT) _xf_groups="$_xf_groups $_xf_group " ;; + RESULT) + [ "$_xf_name" = "$XCAT_FETCH_FILE" ] && [ "$_xf_result" = copied ] || continue + case "$_xf_groups" in + *" $_xf_group "*) return 0 ;; + esac + ;; + esac + done < "$1/status" + return 1 +} + +xcat_uftp_job_state() { + XCAT_JOB_STATE='' XCAT_JOB_START_IN='' XCAT_JOB_REASON='' + xcat_http_get "$XCAT_FETCH_JOB_URL" "$1/job" "$2" || return 1 + while IFS= read -r _xf_line || [ -n "$_xf_line" ]; do + case "$_xf_line" in + state=*) XCAT_JOB_STATE=${_xf_line#state=} ;; + start_in=*) XCAT_JOB_START_IN=${_xf_line#start_in=} ;; + reason=*) XCAT_JOB_REASON=${_xf_line#reason=} ;; + esac + done < "$1/job" + case "$XCAT_JOB_STATE" in + pending|running|done|failed) ;; + *) return 1 ;; + esac + xcat_uint_ok "${XCAT_JOB_START_IN:-0}" 86400 || XCAT_JOB_START_IN=0 +} + +xcat_uftp_attempt() { + _xf_staging=$XCAT_FETCH_DIR.xcat-uftp.$$.$1 + mkdir "$_xf_staging" || return 1 + XCAT_FETCH_STAGING=$_xf_staging + mkdir "$XCAT_FETCH_STAGING/dest" "$XCAT_FETCH_STAGING/tmp" || return 1 + if ! xcat_http_get "$XCAT_FETCH_LISTENER" "$XCAT_FETCH_STAGING/ack" "$XCAT_UFTP_REQUEST_TIMEOUT"; then + xcat_fetch_log warning "UFTP listener did not accept the request $XCAT_FETCH_LISTENER" + return 1 + fi + if ! xcat_uftp_read_ack "$XCAT_FETCH_STAGING/ack" "$XCAT_FETCH_FILE"; then + xcat_fetch_log warning "UFTP listener sent an invalid reply" + return 1 + fi + XCAT_FETCH_JOB_URL="$XCAT_URL_SCHEME://$XCAT_URL_HOST:$XCAT_FETCH_PORT/xcat-uftp/v1/job/$XCAT_ACK_JOB" + xcat_uftp_start_receiver "$XCAT_FETCH_STAGING" || return 1 + xcat_fetch_log info "UFTP transfer $XCAT_ACK_JOB queued; sending starts in ${XCAT_ACK_START_IN}s" + _xf_start=$(xcat_now) + _xf_deadline=$((_xf_start + XCAT_ACK_START_IN + XCAT_ACK_TIMEOUT + XCAT_UFTP_GRACE)) + _xf_cap=$((_xf_deadline + XCAT_ACK_TIMEOUT)) + _xf_next_poll=$((_xf_start + XCAT_UFTP_POLL)) + _xf_done_at= + while _xf_now=$(xcat_now) && [ "$_xf_now" -lt "$_xf_deadline" ]; do + if xcat_uftp_completed "$XCAT_FETCH_STAGING"; then + xcat_uftp_stop_receiver + # Check the type only after the receiver stops: a batch member can deliver a symbolic link. + _xf_file=$XCAT_FETCH_STAGING/dest/$XCAT_FETCH_FILE + if [ -f "$_xf_file" ] && [ ! -L "$_xf_file" ]; then + return 0 + fi + xcat_fetch_log warning "$XCAT_FETCH_FILE from UFTP is not a regular file" + return 1 + fi + if ! xcat_child_running "$XCAT_UFTP_PID"; then + xcat_fetch_log warning "UFTP receiver stopped: $(xcat_last_lines "$XCAT_FETCH_STAGING/uftpd.log" 2>/dev/null)" + return 1 + fi + if [ "$_xf_now" -ge "$_xf_next_poll" ]; then + _xf_next_poll=$((_xf_now + XCAT_UFTP_POLL)) + _xf_budget=$((_xf_deadline - _xf_now)) + [ "$_xf_budget" -le "$XCAT_UFTP_REQUEST_TIMEOUT" ] || _xf_budget=$XCAT_UFTP_REQUEST_TIMEOUT + if xcat_uftp_job_state "$XCAT_FETCH_STAGING" "$_xf_budget"; then + case "$XCAT_JOB_STATE" in + failed) + xcat_fetch_log warning "UFTP transfer $XCAT_ACK_JOB failed on the server: $XCAT_JOB_REASON" + return 1 + ;; + done) + : "${_xf_done_at:=$_xf_now}" + if [ $((_xf_now - _xf_done_at)) -ge "$XCAT_UFTP_GRACE" ]; then + xcat_fetch_log warning "UFTP transfer $XCAT_ACK_JOB ended without this node" + return 1 + fi + ;; + pending) + _xf_wanted=$((_xf_now + XCAT_JOB_START_IN + XCAT_ACK_TIMEOUT + XCAT_UFTP_GRACE)) + [ "$_xf_wanted" -le "$_xf_deadline" ] || _xf_deadline=$_xf_wanted + [ "$_xf_deadline" -le "$_xf_cap" ] || _xf_deadline=$_xf_cap + ;; + esac + fi + fi + sleep 1 + done + xcat_fetch_log warning "UFTP transfer $XCAT_ACK_JOB did not finish in $((_xf_deadline - _xf_start))s" + return 1 +} + +xcat_fetch_interrupted() { + xcat_uftp_cleanup + xcat_fetch_log err "Root image download interrupted" + exit 130 +} + +xcat_uftp_usable() { + if ! xcat_port_ok "$1" || ! xcat_fingerprint_ok "$2"; then + xcat_fetch_log warning "Ignoring malformed xcat.uftp or xcat.uftpfp kernel arguments" + return 1 + fi + if ! xcat_url_parse "$XCAT_FETCH_URL" || [ "$XCAT_URL_SCHEME" != http ]; then + xcat_fetch_log warning "UFTP is used only with http image URLs" + return 1 + fi + XCAT_UFTPD=$(command -v uftpd) || { + xcat_fetch_log warning "uftpd is not in the initramfs; run genimage with uftp installed in the image" + return 1 + } + XCAT_FETCH_LISTENER=$(xcat_uftp_url "$XCAT_FETCH_URL" "$1" "$XCAT_FETCH_SHA256") || { + xcat_fetch_log warning "Cannot build a UFTP request for $XCAT_FETCH_URL" + return 1 + } + XCAT_FETCH_PORT=$1 + XCAT_FETCH_FP=$2 +} + +# xcat_uftp_rootimg +# The checksum file is the sha256sum file published with the image, which +# names the archive to the listener. +xcat_uftp_rootimg() { + XCAT_FETCH_URL=$1 + XCAT_FETCH_FILE=${1##*/} + # uftpd takes only absolute directories. + case "$2" in + /*) XCAT_FETCH_DEST=$2 ;; + *) XCAT_FETCH_DEST=${PWD%/}/$2 ;; + esac + XCAT_FETCH_DIR=${XCAT_FETCH_DEST%/*}/ + XCAT_FETCH_STAGING= + XCAT_UFTP_PID= + XCAT_FETCH_SHA256=$(xcat_listed_sha256 "$3" "$XCAT_FETCH_FILE") || { + xcat_fetch_log warning "$XCAT_FETCH_URL.sha256 lists no SHA-256 checksum for $XCAT_FETCH_FILE" + return 1 + } + xcat_uftp_usable "$4" "$5" || return 1 + trap xcat_fetch_interrupted INT TERM HUP + _xf_attempt=1 + while [ "$_xf_attempt" -le "$XCAT_UFTP_ATTEMPTS" ]; do + if xcat_uftp_attempt "$_xf_attempt" && mv -f "$XCAT_FETCH_STAGING/dest/$XCAT_FETCH_FILE" "$XCAT_FETCH_DEST"; then + xcat_uftp_cleanup + trap - INT TERM HUP + xcat_fetch_log info "Received $XCAT_FETCH_FILE over UFTP" + return 0 + fi + xcat_uftp_cleanup + _xf_attempt=$((_xf_attempt + 1)) + done + trap - INT TERM HUP + return 1 +} diff --git a/xCAT-server/share/xcat/netboot/rh/dracut_033/install.netboot b/xCAT-server/share/xcat/netboot/rh/dracut_033/install.netboot index 6e87e1381..338adc14d 100755 --- a/xCAT-server/share/xcat/netboot/rh/dracut_033/install.netboot +++ b/xCAT-server/share/xcat/netboot/rh/dracut_033/install.netboot @@ -8,6 +8,16 @@ dracut_install ethtool inst "$moddir/xcat-updateflag" "/tmp/updateflag" inst "$moddir/xcatroot" "/sbin/xcatroot" inst_hook cmdline 10 "$moddir/xcat-cmdline.sh" +# genimage adds the UFTP helper only when the image has a UFTP 5 receiver. +if [ -r "$moddir/xcat-rootimg-fetch.sh" ]; then + inst "$moddir/xcat-rootimg-fetch.sh" "/lib/xcat-rootimg-fetch.sh" + for uftpd in /usr/sbin/uftpd /usr/bin/uftpd; do + if [ -x "$uftpd" ]; then + dracut_install "$uftpd" + break + fi + done +fi for file in /etc/udev/rules.d/*;do grep -qi xcat $file && inst $file $file done diff --git a/xCAT-server/share/xcat/netboot/rh/dracut_033/xcatroot b/xCAT-server/share/xcat/netboot/rh/dracut_033/xcatroot index 83e36e1fa..8724653db 100755 --- a/xCAT-server/share/xcat/netboot/rh/dracut_033/xcatroot +++ b/xCAT-server/share/xcat/netboot/rh/dracut_033/xcatroot @@ -52,7 +52,23 @@ rootimg_matches_checksum() { return 1 } +# Downloads the image at $1 to the file $2, over UFTP the first time when nodeset asks for it. +rootimg_download() { + if [ -n "$rootimg_uftp" ]; then + rootimg_uftp_port=$rootimg_uftp + rootimg_uftp= + # nodeset asks for UFTP only with the checksum, which the caller checks on the received file. + if [ "$rootimgchecksum" = yes ] && [ -r /lib/xcat-rootimg-fetch.sh ] && . /lib/xcat-rootimg-fetch.sh && + xcat_uftp_rootimg "$1" "$2" /tmp/rootimg.sha256.before "$rootimg_uftp_port" "$(getarg xcat.uftpfp=)"; then + return 0 + fi + logger $SYSLOGHOST -t $log_label -p local4.warning "Cannot receive $1 over UFTP, downloading it over HTTP..." + fi + wget -nv "$1" +} + rootimgchecksum="$(getarg rootimgchecksum=)" +rootimg_uftp="$(getarg xcat.uftp=)" imgurl="$(getarg imgurl=)"; if [ ! -z "$imgurl" ]; then @@ -69,7 +85,7 @@ if [ ! -z "$imgurl" ]; then [ "$xcatdebugmode" > "0" ] && logger -t $log_label -p local4.debug "Downloading $imgurl..." echo Getting $imgurl... [ "$rootimgchecksum" != yes ] || rootimg_fetch_checksum "$imgurl" /tmp/rootimg.sha256.before || rm -f /tmp/rootimg.sha256.before - if ! wget -nv $imgurl; then + if ! rootimg_download "$imgurl" "$FILENAME"; then logger $SYSLOGHOST -t $log_label -p local4.error "Downloading $imgurl FAILED, retrying..." rm -f $FILENAME echo Failed to get the image, waiting for next retrying... diff --git a/xCAT-server/share/xcat/netboot/rh/dracut_047/install.netboot b/xCAT-server/share/xcat/netboot/rh/dracut_047/install.netboot index 0be90b3ec..0f4add576 100755 --- a/xCAT-server/share/xcat/netboot/rh/dracut_047/install.netboot +++ b/xCAT-server/share/xcat/netboot/rh/dracut_047/install.netboot @@ -8,6 +8,16 @@ dracut_install ethtool inst "$moddir/xcat-updateflag" "/tmp/updateflag" inst "$moddir/xcatroot" "/sbin/xcatroot" inst_hook cmdline 10 "$moddir/xcat-cmdline.sh" +# genimage adds the UFTP helper only when the image has a UFTP 5 receiver. +if [ -r "$moddir/xcat-rootimg-fetch.sh" ]; then + inst "$moddir/xcat-rootimg-fetch.sh" "/lib/xcat-rootimg-fetch.sh" + for uftpd in /usr/sbin/uftpd /usr/bin/uftpd; do + if [ -x "$uftpd" ]; then + dracut_install "$uftpd" + break + fi + done +fi for file in /etc/udev/rules.d/*;do grep -qi xcat $file && inst $file $file done diff --git a/xCAT-server/share/xcat/netboot/rh/dracut_047/xcatroot b/xCAT-server/share/xcat/netboot/rh/dracut_047/xcatroot index 99b7b2331..e0edf3c34 100755 --- a/xCAT-server/share/xcat/netboot/rh/dracut_047/xcatroot +++ b/xCAT-server/share/xcat/netboot/rh/dracut_047/xcatroot @@ -53,7 +53,23 @@ rootimg_matches_checksum() { return 1 } +# Downloads the image at $1 to the file $2, over UFTP the first time when nodeset asks for it. +rootimg_download() { + if [ -n "$rootimg_uftp" ]; then + rootimg_uftp_port=$rootimg_uftp + rootimg_uftp= + # nodeset asks for UFTP only with the checksum, which the caller checks on the received file. + if [ "$rootimgchecksum" = yes ] && [ -r /lib/xcat-rootimg-fetch.sh ] && . /lib/xcat-rootimg-fetch.sh && + xcat_uftp_rootimg "$1" "$2" /tmp/rootimg.sha256.before "$rootimg_uftp_port" "$(getarg xcat.uftpfp=)"; then + return 0 + fi + logger $SYSLOGHOST -t $log_label -p local4.warning "Cannot receive $1 over UFTP, downloading it over HTTP..." + fi + curl --fail "$1" -o "$2" +} + rootimgchecksum="$(getarg rootimgchecksum=)" +rootimg_uftp="$(getarg xcat.uftp=)" imgurl="$(getarg imgurl=)"; if [ ! -z "$imgurl" ]; then @@ -70,7 +86,7 @@ if [ ! -z "$imgurl" ]; then [ "$xcatdebugmode" > "0" ] && logger -t $log_label -p local4.debug "Downloading $imgurl..." echo Getting $imgurl... [ "$rootimgchecksum" != yes ] || rootimg_fetch_checksum "$imgurl" /tmp/rootimg.sha256.before || rm -f /tmp/rootimg.sha256.before - if ! curl --fail $imgurl -o $FILENAME; then + if ! rootimg_download "$imgurl" "$FILENAME"; then logger $SYSLOGHOST -t $log_label -p local4.error "Downloading $imgurl FAILED, retrying..." rm -f $FILENAME echo Failed to get the image, waiting for next retrying... diff --git a/xCAT-server/share/xcat/netboot/rh/dracut_105/stateless/module-setup.sh b/xCAT-server/share/xcat/netboot/rh/dracut_105/stateless/module-setup.sh index b150bd42c..37d48af60 100644 --- a/xCAT-server/share/xcat/netboot/rh/dracut_105/stateless/module-setup.sh +++ b/xCAT-server/share/xcat/netboot/rh/dracut_105/stateless/module-setup.sh @@ -28,6 +28,17 @@ install() { # cmdline hook inst_hook cmdline 10 "$moddir/xcat-cmdline.sh" + # genimage adds the UFTP helper only when the image has a UFTP 5 receiver. + if [ -r "$moddir/xcat-rootimg-fetch.sh" ]; then + inst_simple "$moddir/xcat-rootimg-fetch.sh" "/lib/xcat-rootimg-fetch.sh" + for uftpd in /usr/sbin/uftpd /usr/bin/uftpd; do + if [ -x "$uftpd" ]; then + dracut_install "$uftpd" + break + fi + done + fi + # udev rules with "xcat" for file in /etc/udev/rules.d/*; do if grep -qi xcat "$file"; then diff --git a/xCAT-server/share/xcat/netboot/rh/dracut_105/stateless/xcatroot b/xCAT-server/share/xcat/netboot/rh/dracut_105/stateless/xcatroot index 99b7b2331..e0edf3c34 100755 --- a/xCAT-server/share/xcat/netboot/rh/dracut_105/stateless/xcatroot +++ b/xCAT-server/share/xcat/netboot/rh/dracut_105/stateless/xcatroot @@ -53,7 +53,23 @@ rootimg_matches_checksum() { return 1 } +# Downloads the image at $1 to the file $2, over UFTP the first time when nodeset asks for it. +rootimg_download() { + if [ -n "$rootimg_uftp" ]; then + rootimg_uftp_port=$rootimg_uftp + rootimg_uftp= + # nodeset asks for UFTP only with the checksum, which the caller checks on the received file. + if [ "$rootimgchecksum" = yes ] && [ -r /lib/xcat-rootimg-fetch.sh ] && . /lib/xcat-rootimg-fetch.sh && + xcat_uftp_rootimg "$1" "$2" /tmp/rootimg.sha256.before "$rootimg_uftp_port" "$(getarg xcat.uftpfp=)"; then + return 0 + fi + logger $SYSLOGHOST -t $log_label -p local4.warning "Cannot receive $1 over UFTP, downloading it over HTTP..." + fi + curl --fail "$1" -o "$2" +} + rootimgchecksum="$(getarg rootimgchecksum=)" +rootimg_uftp="$(getarg xcat.uftp=)" imgurl="$(getarg imgurl=)"; if [ ! -z "$imgurl" ]; then @@ -70,7 +86,7 @@ if [ ! -z "$imgurl" ]; then [ "$xcatdebugmode" > "0" ] && logger -t $log_label -p local4.debug "Downloading $imgurl..." echo Getting $imgurl... [ "$rootimgchecksum" != yes ] || rootimg_fetch_checksum "$imgurl" /tmp/rootimg.sha256.before || rm -f /tmp/rootimg.sha256.before - if ! curl --fail $imgurl -o $FILENAME; then + if ! rootimg_download "$imgurl" "$FILENAME"; then logger $SYSLOGHOST -t $log_label -p local4.error "Downloading $imgurl FAILED, retrying..." rm -f $FILENAME echo Failed to get the image, waiting for next retrying... diff --git a/xCAT-server/share/xcat/netboot/rh/genimage b/xCAT-server/share/xcat/netboot/rh/genimage index e6c2543ae..5808728a3 100755 --- a/xCAT-server/share/xcat/netboot/rh/genimage +++ b/xCAT-server/share/xcat/netboot/rh/genimage @@ -1085,6 +1085,10 @@ sub mkinitrd_dracut { my $dracutmpath = $dracutmoduledir . "98xcat/"; mkpath($dracutmpath); + # Only a stateless initial ramdisk that checks the root image receives it over UFTP. + my $uftp_receiver = eval { imgutils::add_uftp_receiver($rootimg_dir, $arch, $dracutmpath, $mode eq 'stateless' && $dracutver >= "033") }; + xdie($@) if $@; + if ($dracutver >= "105") { # Copy xcat module to the image depending on the mode my %files = ( @@ -1299,6 +1303,7 @@ sub mkinitrd_dracut { !system("chroot $rootimg_dir dracut $additional_options -f /tmp/initrd.$$.gz $kernelver") or xdie("Error: failed to generate the initial ramdisk for $mode.\n"); umount_chroot($rootimg_dir); + eval { imgutils::check_uftp_initrd($rootimg_dir, "/tmp/initrd.$$.gz", $mode) } or xdie($@) if $uftp_receiver; if ($native_bootdir) { eval { imgutils::publish_boot_files($native_bootdir, $destdir, @@ -1306,11 +1311,15 @@ sub mkinitrd_dracut { } or xdie($@); print "the initial ramdisk for $mode is generated successfully.\n"; mark_rootimg_check($mode); + mark_uftp_receiver($mode, $uftp_receiver); return; } print "the initial ramdisk for $mode is generated successfully.\n"; - print("Move $rootimg_dir/tmp/initrd.$$.gz -> $destdir/initrd-$mode.gz"); - move("$rootimg_dir/tmp/initrd.$$.gz", "$destdir/initrd-$mode.gz") and mark_rootimg_check($mode); + print("Move $rootimg_dir/tmp/initrd.$$.gz -> $destdir/initrd-$mode.gz\n"); + if (move("$rootimg_dir/tmp/initrd.$$.gz", "$destdir/initrd-$mode.gz")) { + mark_rootimg_check($mode); + mark_uftp_receiver($mode, $uftp_receiver); + } } # nodeset warns unless this marker says that the stateless initial ramdisk checks the root image checksum. @@ -1322,6 +1331,13 @@ sub mark_rootimg_check { print "Warning: $error\n" if $error; } +# nodeset tells a node to receive the root image over UFTP only when this marker matches its initial ramdisk. +sub mark_uftp_receiver { + my ($mode, $receiver) = @_; + return unless $mode eq 'stateless'; + imgutils::mark_uftp_receiver("$destdir/initrd-$mode.gz", $receiver); +} + sub mkinitrd { my ($mode) = @_; # statelite or stateless diff --git a/xCAT-server/share/xcat/netboot/sles/dracut_033/install.netboot b/xCAT-server/share/xcat/netboot/sles/dracut_033/install.netboot index d804091b7..940d5e175 100755 --- a/xCAT-server/share/xcat/netboot/sles/dracut_033/install.netboot +++ b/xCAT-server/share/xcat/netboot/sles/dracut_033/install.netboot @@ -7,3 +7,13 @@ dracut_install parted mke2fs bc mkswap swapon chmod mkfs mkfs.ext4 mkfs.xfs xfs_ inst "$moddir/xcat-updateflag" "/tmp/updateflag" inst "$moddir/xcatroot" "/sbin/xcatroot" inst_hook cmdline 10 "$moddir/xcat-cmdline.sh" +# genimage adds the UFTP helper only when the image has a UFTP 5 receiver. +if [ -r "$moddir/xcat-rootimg-fetch.sh" ]; then + inst "$moddir/xcat-rootimg-fetch.sh" "/lib/xcat-rootimg-fetch.sh" + for uftpd in /usr/sbin/uftpd /usr/bin/uftpd; do + if [ -x "$uftpd" ]; then + dracut_install "$uftpd" + break + fi + done +fi diff --git a/xCAT-server/share/xcat/netboot/sles/dracut_033/xcatroot b/xCAT-server/share/xcat/netboot/sles/dracut_033/xcatroot index eec26ec58..3d5f5cd84 100755 --- a/xCAT-server/share/xcat/netboot/sles/dracut_033/xcatroot +++ b/xCAT-server/share/xcat/netboot/sles/dracut_033/xcatroot @@ -57,7 +57,23 @@ rootimg_matches_checksum() { return 1 } +# Downloads the image at $1 to the file $2, over UFTP the first time when nodeset asks for it. +rootimg_download() { + if [ -n "$rootimg_uftp" ]; then + rootimg_uftp_port=$rootimg_uftp + rootimg_uftp= + # nodeset asks for UFTP only with the checksum, which the caller checks on the received file. + if [ "$rootimgchecksum" = yes ] && [ -r /lib/xcat-rootimg-fetch.sh ] && . /lib/xcat-rootimg-fetch.sh && + xcat_uftp_rootimg "$1" "$2" /tmp/rootimg.sha256.before "$rootimg_uftp_port" "$(getarg xcat.uftpfp=)"; then + return 0 + fi + logger $SYSLOGHOST -t $log_label -p local4.warning "Cannot receive $1 over UFTP, downloading it over HTTP..." + fi + wget -nv "$1" +} + rootimgchecksum="$(getarg rootimgchecksum=)" +rootimg_uftp="$(getarg xcat.uftp=)" if [ ! -z "$imgurl" ]; then if [ xhttp = x${imgurl%%:*} ] || [ xhttps = x${imgurl%%:*} ]; then @@ -73,7 +89,7 @@ if [ ! -z "$imgurl" ]; then [ "$xcatdebugmode" > "0" ] && logger -t $log_label -p debug "downloading $imgurl...." echo Getting $imgurl... [ "$rootimgchecksum" != yes ] || rootimg_fetch_checksum "$imgurl" /tmp/rootimg.sha256.before || rm -f /tmp/rootimg.sha256.before - if ! wget -nv $imgurl; then + if ! rootimg_download "$imgurl" "$FILENAME"; then [ "$xcatdebugmode" > "0" ] && logger -t $log_label -p debug "downloading $imgurl failed,retrying...." rm -f $FILENAME sleep 27 diff --git a/xCAT-server/share/xcat/netboot/sles/genimage b/xCAT-server/share/xcat/netboot/sles/genimage index e6475a64f..24ff1d734 100755 --- a/xCAT-server/share/xcat/netboot/sles/genimage +++ b/xCAT-server/share/xcat/netboot/sles/genimage @@ -1057,6 +1057,10 @@ sub mkinitrd_dracut { my $dracutmpath = $dracutmoduledir . "97xcat/"; mkpath($dracutmpath); + # Only a stateless initial ramdisk that checks the root image receives it over UFTP. + my $uftp_receiver = eval { imgutils::add_uftp_receiver($rootimg_dir, $arch, $dracutmpath, $mode eq 'stateless' && $dracutdir eq 'dracut_033') }; + xdie($@) if $@; + my $perm = (stat("$fullpath/$dracutdir/check"))[2]; cp("$fullpath/$dracutdir/check", $dracutmpath); chmod($perm & 07777, "$dracutmpath/check"); @@ -1177,8 +1181,12 @@ sub mkinitrd_dracut { print "\nchroot $rootimg_dir dracut $additional_options -f /tmp/initrd.$$.gz $kernelver\n"; !system("chroot $rootimg_dir dracut $additional_options -f /tmp/initrd.$$.gz $kernelver") or die("Error: failed to generate the initial ramdisk for $mode.\n"); + eval { imgutils::check_uftp_initrd($rootimg_dir, "/tmp/initrd.$$.gz", $mode) } or xdie($@) if $uftp_receiver; print "the initial ramdisk for $mode is generated successfully.\n"; - move("$rootimg_dir/tmp/initrd.$$.gz", "$destdir/initrd-$mode.gz") and mark_rootimg_check($mode); + if (move("$rootimg_dir/tmp/initrd.$$.gz", "$destdir/initrd-$mode.gz")) { + mark_rootimg_check($mode); + imgutils::mark_uftp_receiver("$destdir/initrd-$mode.gz", $uftp_receiver) if $mode eq 'stateless'; + } } diff --git a/xCAT-server/share/xcat/netboot/ubuntu/dracut/install.netboot b/xCAT-server/share/xcat/netboot/ubuntu/dracut/install.netboot index 0e524272b..7853a7ee2 100755 --- a/xCAT-server/share/xcat/netboot/ubuntu/dracut/install.netboot +++ b/xCAT-server/share/xcat/netboot/ubuntu/dracut/install.netboot @@ -5,6 +5,16 @@ dracut_install grep hostname awk egrep dirname expr ip sed tr cat dracut_install mount.nfs inst "$moddir/xcatroot" "/sbin/xcatroot" inst_hook cmdline 10 "$moddir/xcat-cmdline.sh" +# genimage adds the UFTP helper only when the image has a UFTP 5 receiver. +if [ -r "$moddir/xcat-rootimg-fetch.sh" ]; then + inst "$moddir/xcat-rootimg-fetch.sh" "/lib/xcat-rootimg-fetch.sh" + for uftpd in /usr/sbin/uftpd /usr/bin/uftpd; do + if [ -x "$uftpd" ]; then + dracut_install "$uftpd" + break + fi + done +fi # install netroot dispatcher if available (dracut 051+ with network-legacy) if [ -f "$moddir/../40network/netroot.sh" ]; then inst "$moddir/../40network/netroot.sh" "/sbin/netroot" diff --git a/xCAT-server/share/xcat/netboot/ubuntu/dracut/xcatroot b/xCAT-server/share/xcat/netboot/ubuntu/dracut/xcatroot index 894ceb2cc..3f8b237ce 100755 --- a/xCAT-server/share/xcat/netboot/ubuntu/dracut/xcatroot +++ b/xCAT-server/share/xcat/netboot/ubuntu/dracut/xcatroot @@ -24,6 +24,25 @@ rootimg_matches_checksum() { return 1 } +# Downloads the image at $1 to the file $2, over UFTP the first time when nodeset asks for it. +rootimg_download() { + if [ -n "$rootimg_uftp" ]; then + rootimg_uftp_port=$rootimg_uftp + rootimg_uftp= + # nodeset asks for UFTP only with the checksum, which the caller checks on the received file. + if [ "$rootimgchecksum" = yes ] && [ -r /lib/xcat-rootimg-fetch.sh ] && . /lib/xcat-rootimg-fetch.sh && + xcat_uftp_rootimg "$1" "$2" /tmp/rootimg.sha256.before "$rootimg_uftp_port" "$rootimg_uftp_fp"; then + return 0 + fi + echo "Cannot receive $1 over UFTP, downloading it over HTTP..." + fi + wget "$1" +} + +# The kernel does not pass arguments with a dot to init as variables, so getarg reads them. +rootimg_uftp=$(. /lib/dracut-lib.sh && getarg xcat.uftp=) +rootimg_uftp_fp=$(. /lib/dracut-lib.sh && getarg xcat.uftpfp=) + if [ ! -z "$imgurl" ]; then if [ xhttp = x${imgurl%%:*} ] || [ xhttps = x${imgurl%%:*} ]; then NFS=0 @@ -36,7 +55,7 @@ if [ ! -z "$imgurl" ]; then while [ ! -r "$FILENAME" ]; do echo Getting $imgurl... [ "$rootimgchecksum" != yes ] || rootimg_fetch_checksum "$imgurl" /tmp/rootimg.sha256.before || rm -f /tmp/rootimg.sha256.before - if ! wget $imgurl; then + if ! rootimg_download "$imgurl" "$FILENAME"; then rm -f $FILENAME sleep 27 elif [ "$rootimgchecksum" = yes ] && ! rootimg_check=$(rootimg_matches_checksum "$imgurl" "$FILENAME"); then diff --git a/xCAT-server/share/xcat/netboot/ubuntu/genimage b/xCAT-server/share/xcat/netboot/ubuntu/genimage index 39cdd1736..abfdc8216 100755 --- a/xCAT-server/share/xcat/netboot/ubuntu/genimage +++ b/xCAT-server/share/xcat/netboot/ubuntu/genimage @@ -981,6 +981,10 @@ sub mkinitrd_dracut { } mkpath($dracutmpath); + # Only a stateless initial ramdisk that checks the root image receives it over UFTP. + my $uftp_receiver = eval { imgutils::add_uftp_receiver($rootimg_dir, $arch, $dracutmpath, $mode eq 'stateless') }; + xdie($@) if $@; + my $perm = (stat("$fullpath/$dracutdir/check"))[2]; cp("$fullpath/$dracutdir/check", $dracutmpath); chmod($perm & 07777, "$dracutmpath/check"); @@ -1055,8 +1059,12 @@ sub mkinitrd_dracut { !system("chroot $rootimg_dir dracut -f /tmp/initrd.$$.gz $kernelver") or die("Error: failed to generate the initial ramdisk for $mode.\n"); + eval { imgutils::check_uftp_initrd($rootimg_dir, "/tmp/initrd.$$.gz", $mode) } or xdie($@) if $uftp_receiver; print "the initial ramdisk for $mode is generated successfully.\n"; - move("$rootimg_dir/tmp/initrd.$$.gz", "$destdir/initrd-$mode.gz") and mark_rootimg_check($mode); + if (move("$rootimg_dir/tmp/initrd.$$.gz", "$destdir/initrd-$mode.gz")) { + mark_rootimg_check($mode); + imgutils::mark_uftp_receiver("$destdir/initrd-$mode.gz", $uftp_receiver) if $mode eq 'stateless'; + } } # nodeset warns unless this marker says that the stateless initial ramdisk checks the root image checksum. diff --git a/xCAT-server/xCAT-server.spec b/xCAT-server/xCAT-server.spec index f8cf2766d..2bbeaaaad 100644 --- a/xCAT-server/xCAT-server.spec +++ b/xCAT-server/xCAT-server.spec @@ -385,6 +385,7 @@ cp etc/init.d/xcatd $RPM_BUILD_ROOT/%{prefix}/share/xcat/scripts/xcatd chmod 755 $RPM_BUILD_ROOT/%{prefix}/share/xcat/scripts/xcatd mkdir -p $RPM_BUILD_ROOT/usr/lib/systemd/system cp etc/init.d/xcatd.service $RPM_BUILD_ROOT/usr/lib/systemd/system +cp etc/init.d/xcat-uftp-listener.service $RPM_BUILD_ROOT/usr/lib/systemd/system %else mkdir -p $RPM_BUILD_ROOT/etc/init.d cp etc/init.d/xcatd $RPM_BUILD_ROOT/etc/init.d @@ -453,6 +454,7 @@ rm -rf $RPM_BUILD_ROOT # The compatibility helper owns runtime cleanup. Listing this path as %ghost # would let RPM unlink preserved administrator content after %preun. /usr/lib/systemd/system/xcatd.service +/usr/lib/systemd/system/xcat-uftp-listener.service %else /etc/init.d/xcatd %endif @@ -632,6 +634,7 @@ if [ $1 == 0 ]; then #This means only on -e if [ -f "/proc/cmdline" ]; then # prevent running it during install into chroot image if "$xcatd_init_compat" can-use-systemctl; then systemctl stop xcatd.service + systemctl disable --now xcat-uftp-listener.service >/dev/null 2>&1 || true elif ! "$xcatd_init_compat" uses-systemd --explicit-target && [ -x /etc/init.d/xcatd ]; then /etc/init.d/xcatd stop fi diff --git a/xCAT-test/bats/uftp_listener_removal.bats b/xCAT-test/bats/uftp_listener_removal.bats new file mode 100644 index 000000000..dc988570f --- /dev/null +++ b/xCAT-test/bats/uftp_listener_removal.bats @@ -0,0 +1,63 @@ +#!/usr/bin/env bats + +load 'helpers/shell_source' + +setup() +{ + [ "$(uname -s)" = Linux ] || skip 'package scriptlets run in a Linux sandbox' + bwrap=$(PATH=/usr/bin:/bin type -P bwrap) || { + echo 'Install bubblewrap to run the package removal test' >&2 + return 1 + } + fixture="$BATS_TEST_TMPDIR/fixture" + mkdir -p "$fixture/bin" "$fixture/opt/xcat/share/xcat/scripts" + cat >"$fixture/bin/systemctl" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>/tmp/fixture/systemctl.log +SH + cat >"$fixture/opt/xcat/share/xcat/scripts/xcatd-init-compat" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>/tmp/fixture/compat.log +exit 0 +SH + chmod +x "$fixture/bin/systemctl" "$fixture/opt/xcat/share/xcat/scripts/xcatd-init-compat" + awk '/^%preun$/ { copy = 1; next } copy && !/^%/' "$(repo_path xCAT-server/xCAT-server.spec)" >"$fixture/preun" + [ -s "$fixture/preun" ] + sandbox=("$bwrap" --unshare-all --die-with-parent --new-session + --ro-bind / / --tmpfs /tmp --tmpfs /opt --tmpfs /usr/sbin --proc /proc --dev /dev + --bind "$fixture" /tmp/fixture --bind "$fixture/opt/xcat" /opt/xcat + --ro-bind "$(repo_path xCAT-server/debian/prerm)" /tmp/prerm + --setenv PATH /tmp/fixture/bin:/usr/bin:/bin --setenv RPM_INSTALL_PREFIX0 /opt/xcat) +} + +@test 'RPM erase stops and disables the UFTP listener' { + run "${sandbox[@]}" /bin/bash /tmp/fixture/preun 0 + [ "$status" -eq 0 ] + grep -qx 'stop xcatd.service' "$fixture/systemctl.log" + grep -qx 'disable --now xcat-uftp-listener.service' "$fixture/systemctl.log" +} + +@test 'RPM upgrade leaves the UFTP listener alone' { + run "${sandbox[@]}" /bin/bash /tmp/fixture/preun 1 + [ "$status" -eq 0 ] + if grep -q uftp "$fixture/systemctl.log" 2>/dev/null; then + echo 'an upgrade touched the listener' + return 1 + fi +} + +@test 'Debian removal stops and disables the UFTP listener' { + run "${sandbox[@]}" /bin/sh /tmp/prerm remove + [ "$status" -eq 0 ] + grep -qx 'stop xcatd.service' "$fixture/systemctl.log" + grep -qx 'disable --now xcat-uftp-listener.service' "$fixture/systemctl.log" +} + +@test 'Debian upgrade leaves the UFTP listener alone' { + run "${sandbox[@]}" /bin/sh /tmp/prerm upgrade 2.19.1 + [ "$status" -eq 0 ] + if grep -q uftp "$fixture/systemctl.log" 2>/dev/null; then + echo 'an upgrade touched the listener' + return 1 + fi +} diff --git a/xCAT-test/bats/uftp_rootimg_fetch.bats b/xCAT-test/bats/uftp_rootimg_fetch.bats new file mode 100644 index 000000000..ee9ef7c60 --- /dev/null +++ b/xCAT-test/bats/uftp_rootimg_fetch.bats @@ -0,0 +1,482 @@ +#!/usr/bin/env bats + +load 'helpers/shell_source' + +FP=31:94:09:9D:B1:24:3F:32:C0:B9:1A:BB:AC:F4:74:2B:80:F9:77:B2 +SERVER=192.0.2.1 +NAME=rootimg.cpio.xz +URL=http://$SERVER:80//install/netboot/rhels9/x86_64/compute/$NAME + +setup() +{ + command -v sha256sum >/dev/null || skip 'sha256sum is required' + export FX="$BATS_TEST_TMPDIR/fx" + mkdir -p "$FX/bin" "$FX/root" + head -c 300000 /dev/urandom >"$FX/payload" + SHA=$(sha256sum "$FX/payload") + SHA=${SHA%% *} + printf '%s %s\n' "$SHA" "$NAME" >"$FX/sums" + + cat >"$FX/bin/logger" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"$FX/log" +SH + cat >"$FX/bin/curl" <<'SH' +#!/bin/sh +out= +maxtime= +for arg do + if [ "$prev" = -o ]; then out=$arg; fi + if [ "$prev" = --max-time ]; then maxtime=$arg; fi + case "$arg" in *://*) url=$arg ;; esac + prev=$arg +done +printf '%s\n' "$url" >>"$FX/curl.log" +case "$url" in +*/xcat-uftp/v1/transfer/*) + [ -r "$FX/ack" ] || exit 7 + cp "$FX/ack" "$out" + ;; +*/xcat-uftp/v1/job/*) + if [ -r "$FX/job_delay" ]; then + delay=$(cat "$FX/job_delay") + if [ -n "$maxtime" ] && [ "$maxtime" -lt "$delay" ]; then + sleep "$maxtime" + exit 28 + fi + sleep "$delay" + fi + [ -r "$FX/job" ] || exit 22 + cp "$FX/job" "$out" + ;; +*) exit 22 ;; +esac +SH + cat >"$FX/bin/uftpd" <<'SH' +#!/bin/sh +echo $$ >"$FX/receiver.pid" +printf '%s\n' "$*" >"$FX/uftpd.args" +while [ $# -gt 0 ]; do + case "$1" in + -D) dest=$2; shift ;; + -T) temp=$2; shift ;; + -F) status=$2; shift ;; + -S) cp "$2" "$FX/servers"; shift ;; + -p|-M|-I|-x) shift ;; + esac + shift +done +# uftpd takes only absolute directories. +for dir in "$dest" "$temp"; do + case "$dir" in + /*) ;; + *) echo "ERROR: must specify absolute pathname for $dir" >&2; exit 1 ;; + esac +done +sleeper= +trap 'echo terminated >>"$FX/uftpd.events"; [ "$mode" != swap ] || { rm -f "$dest/rootimg.cpio.xz"; "$LN" -s "$FX/payload" "$dest/rootimg.cpio.xz"; }; [ -z "$sleeper" ] || kill "$sleeper" 2>/dev/null; exit 0' TERM +mode=$(cat "$FX/uftpd.mode" 2>/dev/null || echo deliver) +server=C0000201 +case "$mode" in +exit) echo 'Error binding to interface: the receiver failed' >&2; exit 3 ;; +delayed:*) sleep "${mode#delayed:}"; mode=deliver ;; +stale) server=0A0A0A0A ;; +esac +case "$mode" in +deliver|stale|link|swap) + printf 'CONNECT;2026/10/07-21:05:38;%s;438BAE90;192.0.2.1;mn\n' "$server" >>"$status" + cp "$FX/payload" "$temp/rootimg.cpio.xz" + printf 'RESULT;2026/10/07-21:05:39;%s;438BAE90;rootimg.cpio.xz;293KB;copied\n' "$server" >>"$status" + if [ "$mode" = link ]; then + rm "$temp/rootimg.cpio.xz" + "$LN" -s "$FX/payload" "$dest/rootimg.cpio.xz" + else + mv "$temp/rootimg.cpio.xz" "$dest/rootimg.cpio.xz" + fi + ;; +esac +while :; do + sleep 1 & + sleeper=$! + wait "$sleeper" +done +SH + chmod +x "$FX/bin/"* + mkdir "$FX/tools" + # The initramfs has no tail or head. + for tool in sh cat cp mv rm mkdir sleep kill; do + path=$(PATH=/usr/bin:/bin command -v "$tool") || skip "$tool is required" + ln -s "$path" "$FX/tools/$tool" + done + LN=$(PATH=/usr/bin:/bin command -v ln) || skip 'ln is required' + export LN + PATH="$FX/bin:$PATH" + export XCAT_UFTP_ATTEMPTS=1 XCAT_UFTP_GRACE=2 XCAT_UFTP_POLL=1 XCAT_UFTP_STOP_WAIT=3 + . "$(repo_path xCAT-server/share/xcat/netboot/imgutils/xcat-rootimg-fetch.sh)" +} + +fetch() +{ + PATH="$FX/bin:$FX/tools" xcat_uftp_rootimg "${1:-$URL}" "$FX/root/$NAME" "$FX/sums" "${2-1045}" "${3-$FP}" +} + +# xcatroot names the image file relative to its working directory. +fetch_relative() +{ + (cd "$FX/root" && PATH="$FX/bin:$FX/tools" xcat_uftp_rootimg "$URL" "$NAME" "$FX/sums" 1045 "$FP") +} + +ack() +{ + printf '%s\n' status=queued job=0123456789abcdef "file=$NAME" size=300000 port=1044 \ + mcast=230.4.4.1 server_id=0xC0000201 "server_ip=$SERVER" client_ip=192.0.2.50 "$@" >"$FX/ack" +} + +refute_grep() +{ + if grep -q -- "$1" "$2" 2>/dev/null; then + echo "unexpected '$1' in $2" + return 1 + fi +} + +no_leftovers() +{ + [ -z "$(find "$FX/root" -name '.xcat-uftp*')" ] + if [ -r "$FX/receiver.pid" ] && kill -0 "$(cat "$FX/receiver.pid")" 2>/dev/null; then + echo 'the receiver is still running' + return 1 + fi + if pgrep -f "$FX/bin/uftpd" >/dev/null; then + echo 'a receiver process survived' + return 1 + fi +} + +received() +{ + cmp "$FX/payload" "$FX/root/$NAME" +} + +not_received() +{ + [ ! -e "$FX/root/$NAME" ] +} + +@test 'listener URLs replace only the authority port and keep the scheme' { + run xcat_uftp_url "$URL" 1045 "$SHA" + [ "$output" = "http://$SERVER:1045/xcat-uftp/v1/transfer/$SHA/install/netboot/rhels9/x86_64/compute/$NAME" ] + run xcat_uftp_url http://10.80.0.80/install/x80/$NAME 1045 "$SHA" + [ "$output" = "http://10.80.0.80:1045/xcat-uftp/v1/transfer/$SHA/install/x80/$NAME" ] + run xcat_uftp_url http://mn.cluster:8080/install/img/$NAME 2045 "$SHA" + [ "$output" = "http://mn.cluster:2045/xcat-uftp/v1/transfer/$SHA/install/img/$NAME" ] + run xcat_uftp_url 'http://[2001:db8::1]:80/install/img/rootimg.sfs' 1045 "$SHA" + [ "$output" = "http://[2001:db8::1]:1045/xcat-uftp/v1/transfer/$SHA/install/img/rootimg.sfs" ] + run xcat_uftp_url 'http://[2001:db8::1]/install/img/rootimg.sfs' 1045 "$SHA" + [ "$output" = "http://[2001:db8::1]:1045/xcat-uftp/v1/transfer/$SHA/install/img/rootimg.sfs" ] + run xcat_uftp_url https://mn/install/img/$NAME 1045 "$SHA" + [ "$output" = "https://mn:1045/xcat-uftp/v1/transfer/$SHA/install/img/$NAME" ] +} + +@test 'implicit ports follow the scheme' { + xcat_url_parse http://mn/install/x + [ "$XCAT_URL_PORT" = 80 ] && [ "$XCAT_URL_PATH" = /install/x ] + xcat_url_parse https://mn/install/x + [ "$XCAT_URL_PORT" = 443 ] && [ "$XCAT_URL_SCHEME" = https ] + xcat_url_parse 'http://[fe80::1]:8080' + [ "$XCAT_URL_HOST" = '[fe80::1]' ] && [ "$XCAT_URL_PORT" = 8080 ] && [ "$XCAT_URL_PATH" = / ] +} + +@test 'malformed or ambiguous image URLs are refused' { + for bad in ftp://mn/install/x/$NAME http://user@mn/install/$NAME http://2001:db8::1/install/$NAME \ + http://mn:0/install/$NAME http://mn:99999/install/$NAME http://mn:8a/install/$NAME \ + http://mn/install/../etc/$NAME http://mn/install/./$NAME http://mn/install/.uftp/$NAME \ + "http://mn/install/$NAME?x=1" http://mn/install/%2e%2e/$NAME 'http://[zz::1]/install/x' \ + 'http://[2001:db8::1/install/x' http:///install/x; do + if xcat_uftp_url "$bad" 1045 "$SHA" >/dev/null; then + echo "accepted $bad" + return 1 + fi + done +} + +@test 'the request names the first checksum that the checksum file lists for the image' { + other=$(printf '%064d' 7) + printf '%s rootimg.cpio.gz\n%s *%s\n%s %s\n' "$other" "$SHA" "$NAME" "$other" "$NAME" >"$FX/sums" + run xcat_listed_sha256 "$FX/sums" "$NAME" + [ "$status" -eq 0 ] && [ "$output" = "$SHA" ] + printf 'not-a-digest %s\n%s %s' "$NAME" "$SHA" "$NAME" >"$FX/sums" + run xcat_listed_sha256 "$FX/sums" "$NAME" + [ "$status" -eq 0 ] && [ "$output" = "$SHA" ] + for bad in "$(printf '%s rootimg.cpio.gz' "$SHA")" "$(printf '%s %s' "${SHA%?}" "$NAME")" \ + "$(printf '%s %s' "$(printf '%s' "$SHA" | tr a-f A-F)" "$NAME")" 'busy'; do + printf '%s\n' "$bad" >"$FX/sums" + if xcat_listed_sha256 "$FX/sums" "$NAME" >/dev/null; then + echo "accepted $bad" + return 1 + fi + done + run xcat_listed_sha256 "$FX/missing" "$NAME" + [ "$status" -eq 1 ] && [ -z "$output" ] +} + +@test 'a checksum file without the image is never sent to the listener' { + ack start_in=0 timeout=5 + printf '%064d rootimg.cpio.gz\n' 0 >"$FX/sums" + run fetch + [ "$status" -eq 1 ] + not_received + grep -q "lists no SHA-256 checksum for $NAME" "$FX/log" + [ ! -e "$FX/curl.log" ] +} + +@test 'listener replies are validated and the batch delay defaults to zero' { + ack start_in=7 timeout=60 + xcat_uftp_read_ack "$FX/ack" "$NAME" + [ "$XCAT_ACK_START_IN" = 7 ] && [ "$XCAT_ACK_TIMEOUT" = 60 ] + ack timeout=60 + xcat_uftp_read_ack "$FX/ack" "$NAME" + [ "$XCAT_ACK_START_IN" = 0 ] + ack start_in=0 timeout=60 + xcat_uftp_read_ack "$FX/ack" "$NAME" + [ "$XCAT_ACK_START_IN" = 0 ] + for bad in 'start_in=abc timeout=60' 'start_in=-1 timeout=60' 'start_in=3601 timeout=60' \ + 'timeout=0' 'start_in=00 timeout=60' 'timeout=60 file=rootimg.cpio.gz' \ + 'timeout=60 server_id=0xc0000201' 'timeout=60 job=xyz' 'timeout=60 port=70000' \ + 'timeout=60 client_ip=192.0.2.256' 'timeout=60 status=refused'; do + ack $bad + if xcat_uftp_read_ack "$FX/ack" "$NAME"; then + echo "accepted $bad" + return 1 + fi + done + printf 'status=queued\nnot a key value\n' >"$FX/ack" + if xcat_uftp_read_ack "$FX/ack" "$NAME"; then + echo 'accepted a line without a key' + return 1 + fi +} + +@test 'a UFTP transfer puts the image in place' { + ack start_in=0 timeout=5 + run fetch + [ "$status" -eq 0 ] + received + no_leftovers + grep -q "Received $NAME over UFTP" "$FX/log" + [ "$(cat "$FX/curl.log")" = "http://$SERVER:1045/xcat-uftp/v1/transfer/$SHA/install/netboot/rhels9/x86_64/compute/$NAME" ] + [ "$(cat "$FX/servers")" = "0xC0000201|$SERVER|0|$FP" ] + args=" $(cat "$FX/uftpd.args") " + for option in -d -q -E '-p 1044' '-M 230.4.4.1' '-I 192.0.2.50'; do + case "$args" in + *" $option "*) ;; + *) echo "missing $option"; return 1 ;; + esac + done + grep -q -- "-D $FX/root/.xcat-uftp.[0-9]*.1/dest -T $FX/root/.xcat-uftp.[0-9]*.1/tmp" "$FX/uftpd.args" + grep -q terminated "$FX/uftpd.events" +} + +@test 'an image file named relative to the working directory gets absolute receiver directories' { + ack start_in=0 timeout=5 + run fetch_relative + [ "$status" -eq 0 ] + received + no_leftovers + grep -q -- "-D $FX/root/.xcat-uftp.[0-9]*.1/dest -T $FX/root/.xcat-uftp.[0-9]*.1/tmp" "$FX/uftpd.args" +} + +@test 'completion lines from another server or a stale log do not count' { + ack start_in=0 timeout=1 + echo stale >"$FX/uftpd.mode" + mkdir -p "$FX/root/tmp" + printf 'File transfer complete\nRESULT;x;C0000201;1;%s;1KB;copied\n' "$NAME" >"$FX/root/tmp/uftpd.log" + run fetch + [ "$status" -eq 1 ] + grep -q 'did not finish in 3s' "$FX/log" + not_received + no_leftovers +} + +@test 'a symbolic link that the receiver delivers is never put in place of the image' { + ack start_in=0 timeout=5 + echo link >"$FX/uftpd.mode" + run fetch + [ "$status" -eq 1 ] + [ ! -L "$FX/root/$NAME" ] + not_received + no_leftovers + grep -q "$NAME from UFTP is not a regular file" "$FX/log" +} + +@test 'a symbolic link that replaces the image while the receiver stops is never put in place' { + ack start_in=0 timeout=5 + echo swap >"$FX/uftpd.mode" + run fetch + [ "$status" -eq 1 ] + [ ! -L "$FX/root/$NAME" ] + not_received + no_leftovers + grep -q terminated "$FX/uftpd.events" + grep -q "$NAME from UFTP is not a regular file" "$FX/log" +} + +@test 'a missing receiver sends no request' { + rm "$FX/bin/uftpd" + ack start_in=0 timeout=5 + run fetch + [ "$status" -eq 1 ] + not_received + grep -q 'uftpd is not in the initramfs' "$FX/log" + [ ! -e "$FX/curl.log" ] +} + +@test 'an unavailable listener is asked once for each attempt' { + XCAT_UFTP_ATTEMPTS=2 + run fetch + [ "$status" -eq 1 ] + not_received + no_leftovers + grep -q 'did not accept the request' "$FX/log" + [ "$(grep -c /xcat-uftp/v1/transfer/ "$FX/curl.log")" -eq 2 ] +} + +@test 'a sender failure reported by the server ends the wait early' { + ack start_in=0 timeout=60 + echo hang >"$FX/uftpd.mode" + printf 'state=failed\ntimeout=60\nreason=uftp exited with status 7\n' >"$FX/job" + start=$(date +%s) + run fetch + [ "$status" -eq 1 ] + [ $(( $(date +%s) - start )) -lt 20 ] + grep -q 'failed on the server: uftp exited with status 7' "$FX/log" + not_received + no_leftovers +} + +@test 'a stalled status request cannot extend the transfer deadline' { + ack start_in=0 timeout=2 + echo hang >"$FX/uftpd.mode" + echo 30 >"$FX/job_delay" + printf 'state=running\ntimeout=2\n' >"$FX/job" + start=$(date +%s) + run fetch + [ "$status" -eq 1 ] + [ $(( $(date +%s) - start )) -lt 12 ] + grep -q 'did not finish in 4s' "$FX/log" + not_received + no_leftovers +} + +@test 'a trickling wget status reply cannot extend the transfer deadline' { + rm "$FX/bin/curl" + cat >"$FX/bin/wget" <<'SH' +#!/bin/sh +out= +for arg do + if [ "$prev" = -O ]; then out=$arg; fi + case "$arg" in *://*) url=$arg ;; esac + prev=$arg +done +printf '%s\n' "$url" >>"$FX/curl.log" +case "$url" in +*/xcat-uftp/v1/transfer/*) cp "$FX/ack" "$out" ;; +*/xcat-uftp/v1/job/*) + while :; do + printf x >>"$out" + sleep 1 + done + ;; +*) exit 8 ;; +esac +SH + chmod +x "$FX/bin/wget" + ack start_in=0 timeout=2 + echo hang >"$FX/uftpd.mode" + start=$(date +%s) + run fetch + [ "$status" -eq 1 ] + [ $(( $(date +%s) - start )) -lt 15 ] + grep -q 'did not finish in 4s' "$FX/log" + not_received + no_leftovers + if pgrep -f "$FX/bin/wget" >/dev/null; then + echo 'a wget request survived' + return 1 + fi +} + +@test 'a receiver that exits is detected' { + ack start_in=0 timeout=60 + echo exit >"$FX/uftpd.mode" + run fetch + [ "$status" -eq 1 ] + grep -q 'UFTP receiver stopped: .*the receiver failed' "$FX/log" + not_received + no_leftovers +} + +@test 'a transfer that does not finish in time stops its receiver' { + ack start_in=0 timeout=1 + echo hang >"$FX/uftpd.mode" + run fetch + [ "$status" -eq 1 ] + grep -q 'did not finish in 3s' "$FX/log" + grep -q terminated "$FX/uftpd.events" + not_received + no_leftovers +} + +@test 'a pending job extends the wait within the server deadline' { + ack start_in=0 timeout=3 + echo delayed:6 >"$FX/uftpd.mode" + printf 'state=pending\nstart_in=3\ntimeout=3\n' >"$FX/job" + run fetch + [ "$status" -eq 0 ] + grep -q "Received $NAME over UFTP" "$FX/log" + received +} + +@test 'an interrupted transfer stops its receiver and removes its files' { + ack start_in=0 timeout=60 + echo hang >"$FX/uftpd.mode" + helper=$(repo_path xCAT-server/share/xcat/netboot/imgutils/xcat-rootimg-fetch.sh) + PATH="$FX/bin:$FX/tools" sh -c '. "$1"; xcat_uftp_rootimg "$2" "$3" "$4" 1045 "$5"' fetch "$helper" "$URL" \ + "$FX/root/$NAME" "$FX/sums" "$FP" >"$FX/interrupted.out" 2>&1 & + fetcher=$! + for _ in $(seq 50); do + [ -s "$FX/receiver.pid" ] && break + sleep 0.1 + done + [ -s "$FX/receiver.pid" ] + sleep 1 + kill -TERM "$fetcher" + wait "$fetcher" || rc=$? + [ "${rc:-0}" -eq 130 ] + grep -q 'interrupted' "$FX/log" + not_received + no_leftovers +} + +@test 'an HTTPS image URL is never sent to the listener over HTTP' { + ack start_in=0 timeout=5 + run fetch "https://mn/install/netboot/img/$NAME" + [ "$status" -eq 1 ] + not_received + [ ! -e "$FX/curl.log" ] + grep -q 'UFTP is used only with http image URLs' "$FX/log" +} + +@test 'malformed kernel arguments send no request' { + ack start_in=0 timeout=5 + for args in '70000 '"$FP" "1045 ${FP%:*}" '1045 31:94:09:9d:B1:24:3F:32:C0:B9:1A:BB:AC:F4:74:2B:80:F9:77:B2' "0 $FP"; do + set -- $args + run fetch "$URL" "$1" "$2" + [ "$status" -eq 1 ] + done + run fetch "$URL" '' "$FP" + [ "$status" -eq 1 ] + not_received + [ "$(grep -c 'Ignoring malformed xcat.uftp' "$FX/log")" -eq 5 ] + [ ! -e "$FX/curl.log" ] +} diff --git a/xCAT-test/bats/xcatroot_rootimg_checksum.bats b/xCAT-test/bats/xcatroot_rootimg_checksum.bats index ff4b011be..195707595 100644 --- a/xCAT-test/bats/xcatroot_rootimg_checksum.bats +++ b/xCAT-test/bats/xcatroot_rootimg_checksum.bats @@ -3,6 +3,20 @@ load 'helpers/shell_source' URL=http://192.0.2.1:80//install/netboot/img/rootimg.cpio.gz +FP=31:94:09:9D:B1:24:3F:32:C0:B9:1A:BB:AC:F4:74:2B:80:F9:77:B2 + +ALL_SCRIPTS='xCAT-server/share/xcat/netboot/rh/dracut_033/xcatroot bash +xCAT-server/share/xcat/netboot/rh/dracut_047/xcatroot bash +xCAT-server/share/xcat/netboot/rh/dracut_105/stateless/xcatroot bash +xCAT-server/share/xcat/netboot/sles/dracut_033/xcatroot bash +xCAT-server/share/xcat/netboot/ubuntu/dracut/xcatroot dash' + +# The scripts that receive the root image over UFTP when nodeset asks for it. +UFTP_SCRIPTS='xCAT-server/share/xcat/netboot/rh/dracut_033/xcatroot bash +xCAT-server/share/xcat/netboot/rh/dracut_047/xcatroot bash +xCAT-server/share/xcat/netboot/rh/dracut_105/stateless/xcatroot bash +xCAT-server/share/xcat/netboot/sles/dracut_033/xcatroot bash +xCAT-server/share/xcat/netboot/ubuntu/dracut/xcatroot dash' setup() { @@ -25,6 +39,12 @@ setup() printf 'packed image\n' >"$FX/tree/etc/marker" (cd "$FX/tree" && find . | cpio -H newc -o 2>/dev/null | gzip -c) >"$FX/served/rootimg.cpio.gz" (cd "$FX/served" && sha256sum rootimg.cpio.gz >rootimg.cpio.gz.sha256) + SHA=$(cat "$FX/served/rootimg.cpio.gz.sha256") + SHA=${SHA%% *} + TRANSFER=http://192.0.2.1:1045/xcat-uftp/v1/transfer/$SHA/install/netboot/img/rootimg.cpio.gz + cp "$FX/served/rootimg.cpio.gz" "$FX/payload" + printf '%s\n' status=queued job=0123456789abcdef file=rootimg.cpio.gz port=1044 mcast=230.4.4.1 \ + server_id=0xC0000201 server_ip=192.0.2.1 client_ip=192.0.2.50 start_in=0 timeout=5 >"$FX/ack" cat >"$FX/lib/dracut-lib.sh" <<'SH' getarg() { @@ -50,6 +70,10 @@ SH #!/bin/sh url=$1 out=$2 printf '%s\n' "$url" >>/fx/downloads +case "$url" in +*/xcat-uftp/v1/transfer/*) [ -f /fx/ack ] && cp /fx/ack "$out" && echo 200 || echo 503; exit ;; +*/xcat-uftp/v1/job/*) echo 404; exit ;; +esac name=${url##*/} case "$name" in *.sha256) plan=/fx/checksum.plan ;; *) plan=/fx/image.plan ;; esac set -- $(cat "$plan" 2>/dev/null) @@ -95,9 +119,37 @@ SH for tool in logger mount; do printf '#!/bin/sh\nprintf "%%s\\n" "$*" >>/fx/%s\n' "$tool" >"$FX/bin/$tool" done + # uftpd delivers /fx/payload in a session with the server that the listener named. + cat >"$FX/bin/uftpd" <<'SH' +#!/bin/sh +while [ $# -gt 0 ]; do + case "$1" in + -D) dest=$2; shift ;; + -T) temp=$2; shift ;; + -F) status=$2; shift ;; + -p|-M|-I|-x|-S) shift ;; + esac + shift +done +# uftpd takes only absolute directories. +for dir in "$dest" "$temp"; do + case "$dir" in + /*) ;; + *) echo "ERROR: must specify absolute pathname for $dir" >&2; exit 1 ;; + esac +done +printf 'CONNECT;t;C0000201;1;192.0.2.1;mn\n' >>"$status" +cp /fx/payload "$temp/rootimg.cpio.gz" +printf 'RESULT;t;C0000201;1;rootimg.cpio.gz;1KB;copied\n' >>"$status" +mv "$temp/rootimg.cpio.gz" "$dest/rootimg.cpio.gz" +trap 'exit 0' TERM +while :; do sleep 1 & wait $!; done +SH # A node retries for ever, so the stub stops xcatroot after /fx/maxwaits waits. + # The UFTP receiver and its helper poll every second, which really waits. cat >"$FX/bin/sleep" <<'SH' #!/bin/sh +[ "$*" != 1 ] || exec /usr/bin/sleep 1 printf '%s\n' "$*" >>/fx/sleep [ "$(grep -c . /fx/sleep)" -lt "$(cat /fx/maxwaits)" ] || kill -TERM "$PPID" SH @@ -115,12 +167,14 @@ run_xcatroot() --bind "$FX" /fx --ro-bind "$FX/lib/dracut-lib.sh" /lib/dracut-lib.sh --ro-bind "$(repo_path "$script")" /xcatroot --chdir / --setenv PATH /fx/bin:/usr/bin:/bin --setenv imgurl "$URL" --setenv XCAT 192.0.2.1:3001 - --setenv rootimgchecksum "${CMDLINE#rootimgchecksum=}" "$@") + --setenv rootimgchecksum "${CMDLINE#rootimgchecksum=}" --setenv XCAT_UFTP_POLL 60 "$@") [ ! -e /usr/lib64 ] || sandbox+=(--symlink usr/lib64 /lib64) for multiarch in /usr/lib/*-linux-gnu; do [ ! -d "$multiarch" ] || sandbox+=(--ro-bind "$multiarch" "/lib/${multiarch##*/}") done - echo "imgurl=$URL XCAT=192.0.2.1:3001 nonodestatus $CMDLINE" >"$FX/cmdline" + # genimage puts the helper in every initial ramdisk that can receive the root image over UFTP. + [ -n "$NO_UFTP_HELPER" ] || sandbox+=(--ro-bind "$(repo_path xCAT-server/share/xcat/netboot/imgutils/xcat-rootimg-fetch.sh)" /lib/xcat-rootimg-fetch.sh) + echo "imgurl=$URL XCAT=192.0.2.1:3001 nonodestatus $CMDLINE $UFTP" >"$FX/cmdline" echo "$IMAGE_PLAN" >"$FX/image.plan" echo "$CHECKSUM_PLAN" >"$FX/checksum.plan" echo "${MAXWAITS:-100}" >"$FX/maxwaits" @@ -144,13 +198,7 @@ check_each_script() [ "$(cat "$FX/sleep" 2>/dev/null | grep -c .)" = "$waits" ] || { echo "$script waited: $(cat "$FX/sleep" 2>/dev/null)"; return 1; } case "$script" in */ubuntu/*) log="$FX/console" ;; *) log="$FX/logger" ;; esac [ -z "$message" ] || grep -q -- "$message" "$log" || { echo "$script did not log: $message"; return 1; } - done <"$FX/payload" + SCRIPTS=$UFTP_SCRIPTS UFTP="xcat.uftp=1045 xcat.uftpfp=$FP" + check_each_script 'packed image' "$URL.sha256 $TRANSFER $URL.sha256 $URL.sha256 $URL $URL.sha256 " 1 'rootimg.cpio.gz does not match' +} + +@test 'an image received over UFTP while packimage replaces it matches the checksum file fetched before the request' { + CHECKSUM_PLAN='ok other' + SCRIPTS=$UFTP_SCRIPTS UFTP="xcat.uftp=1045 xcat.uftpfp=$FP" + check_each_script 'packed image' "$URL.sha256 $TRANSFER $URL.sha256 " 0 'Received rootimg.cpio.gz over UFTP' +} + +@test 'an unavailable listener is followed by an HTTP download without a wait' { + rm "$FX/ack" + SCRIPTS=$UFTP_SCRIPTS UFTP="xcat.uftp=1045 xcat.uftpfp=$FP" + check_each_script 'packed image' "$URL.sha256 $TRANSFER $TRANSFER $URL $URL.sha256 " 0 'Cannot receive .*rootimg.cpio.gz over UFTP' +} + +@test 'a checksum file that cannot be downloaded before the request is followed by an HTTP download' { + CHECKSUM_PLAN=503 + SCRIPTS=$UFTP_SCRIPTS UFTP="xcat.uftp=1045 xcat.uftpfp=$FP" + check_each_script 'packed image' "$URL.sha256 $URL $URL.sha256 " 0 'lists no SHA-256 checksum for rootimg.cpio.gz' +} + +@test 'an initial ramdisk without the UFTP helper downloads the image over HTTP' { + NO_UFTP_HELPER=1 SCRIPTS=$UFTP_SCRIPTS UFTP="xcat.uftp=1045 xcat.uftpfp=$FP" + check_each_script 'packed image' "$URL.sha256 $URL $URL.sha256 " 0 'Cannot receive .*rootimg.cpio.gz over UFTP' +} + +@test 'UFTP is not used for an image that is not checked' { + rm "$FX/served/rootimg.cpio.gz.sha256" + CMDLINE= SCRIPTS=$UFTP_SCRIPTS UFTP="xcat.uftp=1045 xcat.uftpfp=$FP" + check_each_script 'packed image' "$URL " 0 'Cannot receive .*rootimg.cpio.gz over UFTP' +} diff --git a/xCAT-test/native/README.md b/xCAT-test/native/README.md index fc5cd53c5..71401eaa8 100644 --- a/xCAT-test/native/README.md +++ b/xCAT-test/native/README.md @@ -144,6 +144,22 @@ Compressor and ctorrent fixtures record what nodes can download during the pack, or fail on request. The image lock and the ilitefile query are test doubles. This case does not boot the image. +The pull-request workflow runs the UFTP listener case explicitly. It starts the +checkout's `xcat-uftp-listener` on loopback addresses and sends it requests +over TCP. It needs Bubblewrap with unprivileged user namespaces, GNU timeout, +the loopback address 127.0.0.2, and the Perl modules of the unit suite, +including DBD::SQLite and Capture::Tiny. Run it as an ordinary user: + +``` +prove xCAT-test/native/uftp_listener.t +``` + +Fixtures replace `uftp` and `uftp_keymgt`. The `uftp` fixture records its +arguments, its host list and the bytes it reads from descriptor 3, or exits +or blocks on request. The case checks key creation, the runtime check on a +read-only log directory, sender processes, timeouts, shutdown and connection +limits. It does not send multicast or boot a node. + The disk, kdump, sudoer and remoteshell cases require bats-core 1.4 or newer, Bubblewrap, GNU timeout, diffutils, Capture::Tiny, File::Slurper and sudo's `visudo`. The remoteshell case also needs `/usr/bin/openssl` and `ssh-keygen` diff --git a/xCAT-test/native/uftp_listener.t b/xCAT-test/native/uftp_listener.t new file mode 100644 index 000000000..55c10f8f3 --- /dev/null +++ b/xCAT-test/native/uftp_listener.t @@ -0,0 +1,387 @@ +#!/usr/bin/env perl +use strict; +use warnings; +use FindBin; +use lib "$FindBin::Bin/../lib"; +use lib "$FindBin::Bin/../../perl-xCAT"; +use lib "$FindBin::Bin/../../xCAT-server/lib/perl"; +use Cwd qw(realpath); +use Digest::SHA qw(sha256_hex); +use File::Path qw(make_path); +use File::Slurper qw(read_binary write_binary); +use File::Temp qw(tempdir); +use IO::Select; +use IO::Socket::INET; +use POSIX qw(WNOHANG); +use Test::More; +use Time::HiRes qw(sleep time); +use XCAT::Test::File qw(repo_path); +use XCAT::Test::Process qw(run_command); +use xCAT::SvrUtils; + +plan skip_all => 'the UFTP listener test binds loopback addresses and uses Linux namespaces' unless $^O eq 'linux'; +my ($bwrap) = grep { -x $_ } qw(/usr/bin/bwrap /bin/bwrap); +die "The UFTP listener test requires bubblewrap\n" unless $bwrap; +die "The UFTP listener test requires GNU timeout\n" unless grep { -x $_ } qw(/usr/bin/timeout /bin/timeout); +my $probe_client = IO::Socket::INET->new(LocalAddr => '127.0.0.2', LocalPort => 0, Proto => 'tcp') + or die "The UFTP listener test cannot bind 127.0.0.2: $!\n"; +close($probe_client); + +my $listener = repo_path('xCAT-server/sbin/xcat-uftp-listener'); +my $modules = repo_path('xCAT-server/lib/perl'); +my $tmp = realpath(tempdir(CLEANUP => 1)); +my $calls = "$tmp/calls"; +my $root = "$tmp/install/netboot"; +my $fp = '31:94:09:9D:B1:24:3F:32:C0:B9:1A:BB:AC:F4:74:2B:80:F9:77:B2'; +make_path($calls, $root, "$tmp/xcatroot/lib/perl", "$tmp/log"); +local $ENV{XCATROOT} = "$tmp/xcatroot"; +local $ENV{FAKE_UFTP_DIR} = $calls; + +# The sender fixture records its arguments, its host list and the bytes of /dev/fd/3. +write_binary("$tmp/fake-uftp", <<'EOF'); +#!/usr/bin/env perl +# The listener looks for: UFTP version 5.0.4 (xCAT build 1) +use strict; +use warnings; +my $dir = $ENV{FAKE_UFTP_DIR}; +my @args = @ARGV; +my %value; +for (my $i = 0; $i < $#args; $i++) { + $value{ $args[$i] } = $args[ $i + 1 ] if $args[$i] =~ /^-[A-Za-z]$/; +} +open(my $argv, '>', "$dir/$$.argv") or die; +print {$argv} join("\n", @args), "\n"; +close($argv); +if (($value{'-H'} // '') =~ /^\@(.+)$/ && open(my $hosts, '<', $1)) { + local $/; + my $list = <$hosts>; + open(my $copy, '>', "$dir/$$.hosts") or die; + print {$copy} $list; + close($copy); +} +open(my $image, '<:raw', $args[-1]) or exit 66; +my $bytes = do { local $/; <$image> }; +open(my $out, '>:raw', "$dir/$$.bytes") or die; +print {$out} $bytes; +close($out); +my $mode = 'exit:0'; +if (open(my $control, '<', "$dir/mode")) { + $mode = <$control>; + chomp($mode); +} +exit $1 if $mode =~ /^exit:(\d+)$/; +if ($mode =~ /^block:(\d+)$/) { + sleep $1; + exit 0; +} +exit 0; +EOF +write_binary("$tmp/fake-keymgt", <<"EOF"); +#!/bin/sh +if [ "\$1" = -g ]; then + printf 'fixture private key\\n' > "\$3" + exit 0 +fi +[ -r "\$1" ] || exit 1 +printf '%s: ECDSA, curve prime256v1, fingerprint: $fp\\n' "\$1" >&2 +EOF +chmod(0755, "$tmp/fake-uftp", "$tmp/fake-keymgt"); + +# packimage publishes the archive with its checksum, and the listener sends only such an archive. +sub publish { + my ($dir, $content) = @_; + make_path($dir); + chmod(0755, $dir); + my ($stage, $error) = xCAT::SvrUtils->rootimg_stage($dir); + die $error unless $stage; + write_binary("$stage/rootimg.cpio.gz", $content); + chmod(0644, "$stage/rootimg.cpio.gz"); + $error = xCAT::SvrUtils->write_rootimg_checksum("$stage/rootimg.cpio.gz") + // xCAT::SvrUtils->publish_rootimg("$stage/rootimg.cpio.gz", "$dir/rootimg.cpio.gz"); + die $error if $error; + return sha256_hex($content); +} + +sub free_port { + my $probe = IO::Socket::INET->new(LocalAddr => '127.0.0.1', LocalPort => 0, Proto => 'tcp', Listen => 1) + or die "probe: $!"; + my $port = $probe->sockport(); + close($probe); + return $port; +} + +my $config_seq = 0; +sub config { + my (%override) = @_; + my %settings = ( + listen_address => '127.0.0.1', + listen_port => free_port(), + image_roots => $root, + batch_delay => 1, + transfer_timeout => 30, + request_timeout => 2, + key_file => "$tmp/server.key", + fingerprint_file => "$tmp/server.fingerprint", + uftp => "$tmp/fake-uftp", + uftp_keymgt => "$tmp/fake-keymgt", + log_dir => "$tmp/log", + %override, + ); + my $path = "$tmp/listener." . ++$config_seq . '.conf'; + write_binary($path, join('', map { "$_ = $settings{$_}\n" } sort keys %settings)); + return ($path, $settings{listen_port}); +} + +sub run_listener { + my ($config, @args) = @_; + return run_command('timeout', '20', $^X, "-I$modules", $listener, '--config', $config, @args); +} + +my %running; +sub start_listener { + my ($config) = @_; + my $log = "$tmp/listener.$config_seq.log"; + my $pid = fork(); + die "fork: $!" unless defined($pid); + if ($pid == 0) { + open(STDOUT, '>', $log) or die; + open(STDERR, '>&', \*STDOUT) or die; + exec($^X, "-I$modules", $listener, '--config', $config) or die; + } + $running{$pid} = 1; + return ($pid, $log); +} + +sub stop_listener { + my ($pid) = @_; + delete $running{$pid}; + kill('TERM', $pid); + my $deadline = time() + 15; + while (time() < $deadline) { + return $? if waitpid($pid, WNOHANG) == $pid; + sleep(0.1); + } + kill('KILL', $pid); + waitpid($pid, 0); + return -1; +} + +END { + for my $pid (keys %running) { + kill('KILL', $pid); + waitpid($pid, 0); + } +} + +sub http { + my ($port, $raw, $client) = @_; + my $sock = IO::Socket::INET->new( + PeerAddr => '127.0.0.1', + PeerPort => $port, + LocalAddr => $client || '127.0.0.2', + Proto => 'tcp', + Timeout => 5, + ) or return { code => 0, error => "$!" }; + print {$sock} $raw; + my $response = ''; + my $select = IO::Select->new($sock); + while ($select->can_read(5)) { + last unless sysread($sock, my $chunk, 4096); + $response .= $chunk; + } + close($sock); + my ($head, $body) = split /\r\n\r\n/, $response, 2; + my ($status) = ($head // '') =~ m{^HTTP/1\.0 (\d{3}) }; + my %record = map { split /=/, $_, 2 } grep { /=/ } split /\n/, $body // ''; + return { %record, code => $status // 0 }; +} + +sub request { + my ($port, $digest, $path, $client) = @_; + return http($port, "GET /xcat-uftp/v1/transfer/$digest$path HTTP/1.0\r\nHost: test\r\n\r\n", $client); +} + +sub job_state { + my ($port, $job, $client) = @_; + return http($port, "GET /xcat-uftp/v1/job/$job HTTP/1.0\r\n\r\n", $client); +} + +sub wait_state { + my ($port, $job, $client, @states) = @_; + my %want = map { $_ => 1 } @states; + my $deadline = time() + 20; + my $state; + while (time() < $deadline) { + $state = job_state($port, $job, $client); + return $state if $want{ $state->{state} // '' }; + sleep(0.2); + } + return $state; +} + +sub wait_port { + my ($port) = @_; + my $deadline = time() + 10; + while (time() < $deadline) { + my $sock = IO::Socket::INET->new(PeerAddr => '127.0.0.1', PeerPort => $port, Proto => 'tcp'); + if ($sock) { + close($sock); + return 1; + } + sleep(0.1); + } + return 0; +} + +sub first_request { + my ($port, @args) = @_; + my $deadline = time() + 10; + my $reply; + while (time() < $deadline) { + $reply = request($port, @args); + return $reply if $reply->{code}; + sleep(0.05); + } + return $reply; +} + +sub sender_calls { + opendir(my $dh, $calls) or return (); + my @pids = sort { $a <=> $b } map { /^(\d+)\.argv$/ ? $1 : () } readdir($dh); + closedir($dh); + return map { + my $pid = $_; + { pid => $pid, argv => [ split /\n/, read_binary("$calls/$pid.argv") ], + hosts => -e "$calls/$pid.hosts" ? read_binary("$calls/$pid.hosts") : '', + bytes => -e "$calls/$pid.bytes" ? read_binary("$calls/$pid.bytes") : undef } + } @pids; +} + +sub wait_call { + my ($hosts) = @_; + my $deadline = time() + 10; + while (time() < $deadline) { + my ($call) = grep { $_->{hosts} eq $hosts && defined($_->{bytes}) } sender_calls(); + return $call if $call; + sleep(0.1); + } + return undef; +} + +sub reset_calls { + unlink(glob("$calls/*")); +} + +sub mode { + write_binary("$calls/mode", "$_[0]\n"); +} + +my ($check_config) = config(); +my ($rc, $out) = run_listener($check_config, '--check'); +is($rc, 1, 'the listener refuses to start without a server key'); +like($out, qr/run xcat-uftp-listener --init-key/, 'the missing key error says how to create it'); +($rc, $out) = run_listener($check_config, '--init-key'); +is($rc, 0, 'the server key is created') or diag($out); +is((stat("$tmp/server.key"))[2] & 07777, 0600, 'the server key is readable only by its owner'); +is(scalar(read_binary("$tmp/server.fingerprint")), "$fp\n", 'the fingerprint that uftp_keymgt reports is published for nodeset'); +($rc, $out) = run_listener($check_config, '--check'); +is($rc, 0, 'the configuration check passes') or diag($out); +like($out, qr/server key fingerprint \Q$fp\E/, 'the check reports the fingerprint of the server key'); + +# The service sandbox mounts every path except its LogsDirectory read-only, and the listener runs as root. +make_path("$tmp/readonly-log"); +my ($readonly_config) = config(log_dir => "$tmp/readonly-log"); +($rc, $out) = run_command('timeout', '20', $bwrap, '--dev-bind', '/', '/', '--ro-bind', "$tmp/readonly-log", "$tmp/readonly-log", + '--unshare-user', '--uid', '0', '--gid', '0', '--', $^X, "-I$modules", $listener, '--config', $readonly_config, '--check'); +isnt($rc, 0, 'the check fails as root when log_dir is on a read-only mount'); +like($out, qr/Cannot write to \Q$tmp\E\/readonly-log: Read-only file system/, 'the check names the log directory and the reason'); + +my $image_a = "$root/rhels9/x86_64/compute"; +my $content_a = "image A generation 1\n" x 200; +my $sum_a = publish($image_a, $content_a); +my $path_a = "$image_a/rootimg.cpio.gz"; + +my ($main_config, $port) = config(batch_delay => 2); +my ($pid, $main_log) = start_listener($main_config); +my $first = first_request($port, $sum_a, $path_a, '127.0.0.2'); +is($first->{code}, 200, 'the first connection after startup is answered as a transfer request'); +is($first->{client_ip}, '127.0.0.2', 'the reply gives the address of the connection'); +($rc, $out) = run_listener($main_config); +is($rc, 1, 'a second listener on an occupied port exits with an error'); +like($out, qr/Cannot listen on 127\.0\.0\.1:$port: Address already in use/, 'the occupied port is reported'); +is(request($port, $sum_a, $path_a, '127.0.0.3')->{job}, $first->{job}, 'a request from another node joins the job'); + +my $done = wait_state($port, $first->{job}, '127.0.0.2', 'done', 'failed'); +is($done->{state}, 'done', 'the job completes when its sender exits'); +my @sent = sender_calls(); +is(scalar(@sent), 1, 'one sender serves the job'); +is($sent[0]{hosts}, "127.0.0.2\n127.0.0.3\n", 'the sender reads the host list of the job'); +is($sent[0]{bytes}, $content_a, 'the sender reads the published bytes from its inherited descriptor'); +is_deeply([ @{ $sent[0]{argv} }[ -3 .. -1 ] ], [ '-D', 'rootimg.cpio.gz', '/dev/fd/3' ], 'the sender reads the image from descriptor 3'); +like(scalar(read_binary("$tmp/log/job-$first->{job}.log")), qr/^starting: \Q$tmp\E\/fake-uftp /m, 'the job log records the sender command'); +like(scalar(read_binary($main_log)), qr/job \Q$first->{job}\E: done$/m, 'the listener log reports the finished job'); +reset_calls(); + +mode('exit:7'); +my $failing = request($port, $sum_a, $path_a, '127.0.0.5'); +my $failed = wait_state($port, $failing->{job}, '127.0.0.5', 'failed', 'done'); +is($failed->{state}, 'failed', 'a sender failure fails the job'); +is($failed->{reason}, 'uftp exited with status 7', 'the job reports the exit status of the sender'); +mode('exit:0'); +reset_calls(); + +my $idle = IO::Socket::INET->new(PeerAddr => '127.0.0.1', PeerPort => $port, LocalAddr => '127.0.0.2', Proto => 'tcp'); +my $idle_started = time(); +my $idle_read = sysread($idle, my $nothing, 10); +ok(defined($idle_read) && $idle_read == 0 && time() - $idle_started < 6, 'an idle connection is closed after the request timeout'); +close($idle); +is(stop_listener($pid), 0, 'the listener stops cleanly'); + +my $image_b = "$root/rhels9/x86_64/service"; +my $sum_b = publish($image_b, "image B\n" x 100); +my ($limited, $limited_port) = config(batch_delay => 0, max_transfers => 1, transfer_timeout => 3); +($pid) = start_listener($limited); +wait_port($limited_port); +mode('block:20'); +my $blocking = request($limited_port, $sum_a, $path_a, '127.0.0.2'); +is(wait_state($limited_port, $blocking->{job}, '127.0.0.2', 'running')->{state}, 'running', 'the transfer starts'); +# A waiting job fails after transfer_timeout, so it must arrive at least a second after the running one started. +sleep(1.5); +my $waiting = request($limited_port, $sum_b, "$image_b/rootimg.cpio.gz", '127.0.0.3'); +is(job_state($limited_port, $waiting->{job}, '127.0.0.3')->{state}, 'pending', 'a second image waits while the transfer limit is reached'); +my $timed_out = wait_state($limited_port, $blocking->{job}, '127.0.0.2', 'failed', 'done'); +like($timed_out->{reason}, qr/transfer exceeded 3s/, 'a sender that exceeds its timeout fails the job'); +my $blocked_call = wait_call("127.0.0.2\n"); +ok($blocked_call && !kill(0, $blocked_call->{pid}), 'the timed out sender was stopped'); +is(wait_state($limited_port, $waiting->{job}, '127.0.0.3', 'running')->{state}, 'running', 'the waiting transfer starts when the slot frees'); +my $held = wait_call("127.0.0.3\n"); +is(stop_listener($pid), 0, 'the listener stops while a transfer runs'); +ok($held && !kill(0, $held->{pid}), 'stopping the listener stops its senders'); +mode('exit:0'); +reset_calls(); + +my ($crowded, $crowded_port) = config(max_connections => 2, request_timeout => 30, allowed_networks => '127.0.0.2/32'); +($pid) = start_listener($crowded); +wait_port($crowded_port); +sleep(0.5); +my @held = map { + IO::Socket::INET->new(PeerAddr => '127.0.0.1', PeerPort => $crowded_port, LocalAddr => '127.0.0.2', Proto => 'tcp') +} 1 .. 2; +sleep(0.5); +my $fds_before = () = glob("/proc/$pid/fd/*"); +my @refused; +for (1 .. 20) { + my $started = time(); + my $reply = http($crowded_port, ''); + push @refused, $reply->{code} == 503 && time() - $started < 2 ? 1 : 0; +} +is(scalar(grep { $_ } @refused), 20, 'connections beyond max_connections are refused at once'); +my $fds_after = () = glob("/proc/$pid/fd/*"); +is($fds_after, $fds_before, 'refused connections are not kept open'); +close($_) for @held; +sleep(0.5); +is(request($crowded_port, $sum_a, $path_a, '127.0.0.3')->{code}, 403, 'the address of the connection is checked against allowed_networks'); +is(request($crowded_port, $sum_a, $path_a, '127.0.0.2')->{code}, 200, 'the listener serves requests again when connections close'); +stop_listener($pid); + +done_testing(); diff --git a/xCAT-test/unit/imgport_rootimg_checksum.t b/xCAT-test/unit/imgport_rootimg_checksum.t index d264bcf78..a380a7d41 100644 --- a/xCAT-test/unit/imgport_rootimg_checksum.t +++ b/xCAT-test/unit/imgport_rootimg_checksum.t @@ -209,6 +209,38 @@ for my $failed (qw(rootimg.cpio.gz.sha256 rootimg.cpio.gz)) { 'the marker of the bundled initial ramdisk is placed next to it'); } +{ + # nodeset sends a UFTP image only to an initial ramdisk with a matching marker, so an exported image keeps it. + my $dir = "$fixture/install/netboot/rhels9.6/x86_64/uftp"; + make_path($dir); + write_text("$dir/$_", "$_\n") for qw(kernel initrd-stateless.gz rootimg.cpio.gz compute.pkglist); + write_text("$dir/initrd-stateless.gz.uftp", "sha256=marker of the ramdisk with the receiver\n"); + my $attrs = { osimage => { provmethod => 'netboot', osvers => 'rhels9.6', osarch => 'x86_64', profile => 'uftp' }, + linuximage => { rootimgdir => $dir, pkglist => "$dir/compute.pkglist" } }; + my @errors; + xCAT_plugin::imgport::get_files('uftp', sub { push @errors, @{ $_[0]{error} || [] } }, $attrs); + is(join("\n", @errors), '', 'the files of an image with the UFTP marker are found'); + is($attrs->{ramdiskuftp}, "$dir/initrd-stateless.gz.uftp", 'the export carries the UFTP marker of the initial ramdisk'); + + my ($placed, $errors, $dest) = import_image('rootimg.cpio.gz', sub { + write_text("$fixture/bundle-$sequence/initrd-stateless.gz.uftp", "sha256=marker of the bundled ramdisk\n"); + }, ramdiskuftp => 'DEST/initrd-stateless.gz.uftp'); + is($errors, '', 'a bundle with the UFTP marker is placed'); + is(read_binary("$dest/initrd-stateless.gz.uftp"), "sha256=marker of the bundled ramdisk\n", + 'the UFTP marker of the bundled initial ramdisk is placed next to it'); + + # A bundle that names the UFTP marker but lacks it changes nothing. + ($placed, $errors, $dest) = import_image('rootimg.cpio.gz', sub { + write_text("$_[0]/initrd-stateless.gz.uftp", "sha256=marker of the published ramdisk\n"); + }, ramdiskuftp => 'DEST/initrd-stateless.gz.uftp'); + ok(!$placed, 'an import fails when the bundle lacks the UFTP marker that it names'); + like($errors, qr/The image bundle lacks initrd-stateless\.gz\.uftp\./, 'the missing UFTP marker is reported'); + is(read_binary("$dest/rootimg.cpio.gz"), $published, 'the published image stays when the bundle lacks the UFTP marker'); + is(read_binary("$dest/initrd-stateless.gz"), "published ramdisk\n", 'the published ramdisk stays when the bundle lacks the UFTP marker'); + is(read_binary("$dest/initrd-stateless.gz.uftp"), "sha256=marker of the published ramdisk\n", + 'the UFTP marker of the published ramdisk stays'); +} + for my $nodes (qw(psnode undefined-node)) { # The nodes that get the postscripts of the image exist, or not. my ($placed, $errors, $dest, $archive) = import_image('rootimg.cpio.gz', undef, nodes => $nodes); diff --git a/xCAT-test/unit/imgutils_uftp_receiver.t b/xCAT-test/unit/imgutils_uftp_receiver.t new file mode 100644 index 000000000..bb3bcbe08 --- /dev/null +++ b/xCAT-test/unit/imgutils_uftp_receiver.t @@ -0,0 +1,137 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +use Cwd qw(realpath); +use File::Path qw(make_path); +use File::Slurper qw(read_binary write_binary); +use File::Temp qw(tempdir); +use FindBin; +use lib "$FindBin::Bin/../../perl-xCAT"; +use lib "$FindBin::Bin/../../xCAT-server/share/xcat/netboot/imgutils"; +use lib "$FindBin::Bin/../../xCAT-server/lib/perl"; +use Test::More; + +use imgutils; +use xCAT::UFTP; + +my $tmp = realpath(tempdir(CLEANUP => 1)); +my $helper = realpath("$FindBin::Bin/../../xCAT-server/share/xcat/netboot/imgutils/xcat-rootimg-fetch.sh"); + +# Returns what the code printed and what it returned or died with. +sub output_of { + my ($code) = @_; + my $out = ''; + open(my $capture, '>', \$out) or die $!; + my $previous = select($capture); + my @result = eval { $code->() }; + my $error = $@; + select($previous); + close($capture); + return ($out, $error, @result); +} + +# An image root with an x86_64 uftpd of the UFTP version $version. +sub image_root { + my ($name, $version) = @_; + my $root = "$tmp/image-$name"; + make_path("$root/usr/bin"); + my $elf = "\x7fELF\x02\x01\x01" . ("\0" x 9) . pack('v', 2) . pack('v', 62) . ("\0" x 44); + if ($version) { + write_binary("$root/usr/bin/uftpd", $elf . "\0UFTP version $version Copyright\0"); + chmod(0755, "$root/usr/bin/uftpd"); + } + return $root; +} + +my $module = "$tmp/98xcat"; +my $copy = "$module/xcat-rootimg-fetch.sh"; +make_path($module); + +my ($out, $error, $receiver) = output_of(sub { imgutils::add_uftp_receiver(image_root('uftp5', '5.0.3 (xCAT build 1)'), 'x86_64', $module, 1) }); +is($error, '', 'a stateless image with a UFTP 5 receiver is accepted'); +is($receiver, '/usr/bin/uftpd', 'the receiver of the image is returned'); +is(scalar(read_binary($copy)), scalar(read_binary($helper)), 'the dracut module gets the UFTP fetch helper'); +is((stat($copy))[2] & 07777, 0644, 'the dracut module installs the helper itself, so the copy is not executable'); +like($out, qr{Adding the UFTP receiver /usr/bin/uftpd to the initial ramdisk}, 'genimage reports the receiver'); + +($out, $error, $receiver) = output_of(sub { imgutils::add_uftp_receiver(image_root('uftp5', '5.0.3 (xCAT build 1)'), 'x86_64', $module, 0) }); +ok(!defined($receiver) && $error eq '', 'an initial ramdisk that must not receive over UFTP gets no receiver'); +ok(!-e $copy, 'the helper that an earlier stateless build left in the module is removed'); + +write_binary($copy, "older helper\n"); +($out, $error, $receiver) = output_of(sub { imgutils::add_uftp_receiver(image_root('none'), 'x86_64', $module, 1) }); +ok(!defined($receiver) && $error eq '', 'an image without uftpd gets no receiver'); +is($out, '', 'an image without uftpd gets no warning'); +ok(!-e $copy, 'the helper is removed when the image no longer has uftpd'); + +symlink("$tmp/missing", $copy) or die $!; +($out, $error, $receiver) = output_of(sub { imgutils::add_uftp_receiver(image_root('uftp4', '4.10.2'), 'x86_64', $module, 1) }); +ok(!defined($receiver) && $error eq '', 'a UFTP 4 receiver is left out without failing the image build'); +like($out, qr/Warning: .*UFTP 5\.x is required/, 'a UFTP 4 receiver gets a warning'); +ok(!-e $copy && !-l $copy, 'a dangling helper link is removed'); + +($out, $error, $receiver) = output_of(sub { imgutils::add_uftp_receiver(image_root('stock', '5.0.3'), 'x86_64', $module, 1) }); +ok(!defined($receiver) && $error eq '', 'a UFTP 5 receiver without the xCAT build mark is left out'); +like($out, qr/Warning: .*UFTP 5\.0\.3 without the xCAT fixes/, 'a UFTP 5 receiver without the xCAT build mark gets a warning'); + +($out, $error, $receiver) = output_of(sub { imgutils::add_uftp_receiver(image_root('uftp5', '5.0.3 (xCAT build 1)'), 'ppc64le', $module, 1) }); +ok(!defined($receiver), 'a receiver for another architecture than the image is left out'); +like($out, qr/Warning: .*another architecture than ppc64le/, 'a foreign receiver gets a warning'); +ok(!-e $copy, 'no helper is added without a usable receiver'); + +# unlink and copy fail on these paths for root too. +make_path("$copy/older"); +(undef, $error) = output_of(sub { imgutils::add_uftp_receiver(image_root('none'), 'x86_64', $module, 1) }); +like($error, qr/^Error: cannot remove \Q$copy\E/, 'an older helper that cannot be removed fails the image build'); +rmdir("$copy/older") && rmdir($copy) or die $!; +my $missing_module = "$tmp/missing/98xcat"; +(undef, $error) = output_of(sub { imgutils::add_uftp_receiver(image_root('uftp5', '5.0.3 (xCAT build 1)'), 'x86_64', $missing_module, 1) }); +like($error, qr{^Error: cannot copy \S+/imgutils/xcat-rootimg-fetch\.sh to \Q$missing_module/xcat-rootimg-fetch.sh\E}, + 'a helper that cannot be added fails the image build'); + +# lsinitrd runs in the image, so the test replaces the listing that it returns. +my (@listed, $listing); +{ + no warnings 'redefine'; + *imgutils::initrd_listing = sub { push @listed, [@_]; return $listing }; +} +$listing = [ + "-rwxr-xr-x 1 root root 180000 Jan 1 00:00 usr/bin/uftpd\n", + "-rwxr-xr-x 1 root root 40000 Jan 1 00:00 usr/bin/sha256sum\n", + "-rw-r--r-- 1 root root 12000 Jan 1 00:00 usr/lib/xcat-rootimg-fetch.sh\n", +]; +my $root = image_root('uftp5', '5.0.3 (xCAT build 1)'); +(undef, $error, my $checked) = output_of(sub { imgutils::check_uftp_initrd($root, '/tmp/initrd.1.gz', 'stateless') }); +ok($checked && $error eq '', 'an initial ramdisk with the receiver files passes'); +is_deeply(\@listed, [ [ $root, '/tmp/initrd.1.gz' ] ], 'the initial ramdisk is listed in the image'); +$listing = [ @$listing[ 1, 2 ] ]; +(undef, $error) = output_of(sub { imgutils::check_uftp_initrd($root, '/tmp/initrd.1.gz', 'stateless') }); +like($error, qr/^Error: the initial ramdisk for stateless lacks the UFTP receiver files: uftpd$/m, + 'an initial ramdisk without uftpd fails the image build'); +$listing = undef; +(undef, $error) = output_of(sub { imgutils::check_uftp_initrd($root, '/tmp/initrd.1.gz', 'stateless') }); +like($error, qr/lacks the UFTP receiver files: a readable file list/, 'an initial ramdisk that cannot be listed fails the image build'); + +my $initrd = "$tmp/initrd-stateless.gz"; +write_binary($initrd, "initial ramdisk with the receiver\n"); +($out, $error, my $marked) = output_of(sub { imgutils::mark_uftp_receiver($initrd, '/usr/bin/uftpd') }); +ok($marked && $error eq '', 'an initial ramdisk with the receiver is marked'); +is(xCAT::UFTP::verify_initrd_marker($initrd), undef, 'nodeset accepts the marked initial ramdisk'); +like($out, qr/\Q$initrd\E can receive the root image over UFTP/, 'genimage reports the UFTP receiver'); + +write_binary($initrd, "initial ramdisk without the receiver\n"); +($out, $error, $marked) = output_of(sub { imgutils::mark_uftp_receiver($initrd, undef) }); +ok($marked && $error eq '' && $out eq '', 'an initial ramdisk without the receiver is not marked'); +ok(!-e "$initrd.uftp", 'the marker of the previous initial ramdisk is removed'); +like(xCAT::UFTP::verify_initrd_marker($initrd), qr/was not built with the UFTP receiver/, 'nodeset refuses UFTP for it'); + +# A file cannot replace a directory, for root too. +make_path("$initrd.uftp/older"); +($out, $error, $marked) = output_of(sub { imgutils::mark_uftp_receiver($initrd, '/usr/bin/uftpd') }); +is($error, '', 'a marker that cannot be written does not fail the image build'); +ok(!$marked, 'a marker that cannot be written is reported to the caller'); +like($out, qr/^Warning: Cannot publish \Q$initrd\E\.uftp: /, 'a marker that cannot be written gets a warning'); +is(scalar(() = glob("$tmp/.initrd-stateless.gz.uftp.tmp*")), 0, 'a marker that cannot be written leaves no temporary file'); + +done_testing(); diff --git a/xCAT-test/unit/nodeset_rootimg_uftp.t b/xCAT-test/unit/nodeset_rootimg_uftp.t new file mode 100644 index 000000000..269b20a99 --- /dev/null +++ b/xCAT-test/unit/nodeset_rootimg_uftp.t @@ -0,0 +1,189 @@ +#!/usr/bin/env perl +use strict; +use warnings; +use FindBin; +use lib "$FindBin::Bin/../lib"; +use lib "$FindBin::Bin/../../perl-xCAT"; +use lib "$FindBin::Bin/../../xCAT-server/lib/perl"; +use Cwd qw(realpath); +use File::Path qw(make_path); +use File::Slurper qw(read_text write_text); +use File::Temp qw(tempdir); +use Test::More; +use XCAT::Test::File qw(repo_path); + +my $fixture = realpath(tempdir(CLEANUP => 1)); +make_path("$fixture/db", "$fixture/root/lib/perl", "$fixture/tftpboot", "$fixture/etc"); +symlink(repo_path('perl-xCAT/xCAT'), "$fixture/root/lib/perl/xCAT") or die $!; +$ENV{XCATROOT} = "$fixture/root"; +$ENV{XCATCFG} = "SQLite:$fixture/db"; + +require xCAT::Table; +require xCAT::SvrUtils; +require xCAT::UFTP; +require xCAT::DBobjUtils; +require(repo_path('xCAT-server/lib/xcat/plugins/anaconda.pm')); +require(repo_path('xCAT-server/lib/xcat/plugins/sles.pm')); +require(repo_path('xCAT-server/lib/xcat/plugins/debian.pm')); + +my $fp = '31:94:09:9D:B1:24:3F:32:C0:B9:1A:BB:AC:F4:74:2B:80:F9:77:B2'; +my $uftp = qr/(?:^| )xcat\.uftp=2045 xcat\.uftpfp=\Q$fp\E /; +write_text("$fixture/etc/server.fingerprint", "$fp\n"); +write_text("$fixture/etc/listener.conf", "listen_address = 192.0.2.1\nlisten_port = 2045\n" + . "image_roots = $fixture/install/netboot\nfingerprint_file = $fixture/etc/server.fingerprint\n"); +{ + no warnings 'once'; + $xCAT::UFTP::CONFIG_FILE = "$fixture/etc/listener.conf"; +} + +my %plugin = (anaconda => 'rhels9.6', sles => 'sle15.6', debian => 'ubuntu24.04'); +my %dir; +my %tables = ( + site => [ [ { key => 'installdir' }, { value => "$fixture/install" } ], [ { key => 'tftpdir' }, { value => "$fixture/tftpboot" } ] ], + nodelist => [ map { [ { node => $_ }, { groups => 'compute' } ] } qw(n1 n2 n3) ], + noderes => [ map { [ { node => $_ }, { xcatmaster => '192.0.2.1' } ] } qw(n1 n2 n3) ], +); +for my $name (keys %tables) { + my $table = xCAT::Table->new($name, -create => 1) or die "cannot open $name"; + $table->setAttribs(@$_) for @{ $tables{$name} }; +} +my $nodetype = xCAT::Table->new('nodetype', -create => 1); +my $osimage = xCAT::Table->new('osimage', -create => 1); +my $linuximage = xCAT::Table->new('linuximage', -create => 1); +for my $name (sort keys %plugin) { + $dir{$name} = "$fixture/install/netboot/$name"; + make_path($dir{$name}); + write_text("$dir{$name}/$_", "$_ of $name\n") for qw(kernel initrd-stateless.gz rootimg.cpio.gz); + $osimage->setAttribs({ imagename => $name }, { osvers => $plugin{$name}, osarch => 'x86_64', profile => 'compute', provmethod => 'netboot' }); + $linuximage->setAttribs({ imagename => $name }, { rootimgdir => $dir{$name} }); +} + +# The anaconda plugin also sets up a statelite node whose ramdisk root image is downloaded. +$dir{ramdisk} = "$fixture/install/netboot/ramdisk"; +make_path($dir{ramdisk}); +write_text("$dir{ramdisk}/$_", "$_ of ramdisk\n") for qw(kernel initrd-stateless.gz initrd-statelite.gz rootimg-statelite.gz); +$osimage->setAttribs({ imagename => 'ramdisk' }, + { osvers => 'rhels9.6', osarch => 'x86_64', profile => 'compute', provmethod => 'statelite', rootfstype => 'ramdisk' }); +$linuximage->setAttribs({ imagename => 'ramdisk' }, { rootimgdir => $dir{ramdisk} }); + +# genimage built every initial ramdisk with the UFTP receiver and the root image check. +sub mark { + my ($name) = @_; + is(xCAT::UFTP::write_initrd_marker("$dir{$name}/initrd-stateless.gz"), undef, "genimage marks the initial ramdisk of $name"); + is(xCAT::SvrUtils->write_initrd_check_marker("$dir{$name}/initrd-stateless.gz"), undef, "genimage marks the check of $name"); +} +mark($_) for (sort keys %plugin), 'ramdisk'; + +# Runs nodeset for n1 and n2 with the image of $name, and n3 with the statelite ramdisk image for anaconda. +# Returns the kernel command lines and the errors. +sub nodeset { + my ($name, %opt) = @_; + $nodetype->setAttribs({ node => $_ }, { os => $plugin{$name}, arch => 'x86_64', profile => 'compute', provmethod => $name }) for qw(n1 n2); + $nodetype->setAttribs({ node => 'n3' }, { os => 'rhels9.6', arch => 'x86_64', profile => 'compute', provmethod => 'ramdisk' }); + no warnings 'once'; + local %::XCATSITEVALS = (installdir => "$fixture/install", tftpdir => "$fixture/tftpboot", httpmethod => 'http', %{ $opt{site} || {} }); + my $bootparams = { map { ($_ => [ { addkcmdline => $opt{node_addkcmdline}{$_} } ]) } keys %{ $opt{node_addkcmdline} || {} } }; + my @responses; + my @requests = ([ mknetboot => qw(n1 n2) ]); + push @requests, [ mkstatelite => 'n3' ] if $name eq 'anaconda'; + for my $request (@requests) { + my ($command, @nodes) = @$request; + no strict 'refs'; + &{"xCAT_plugin::${name}::process_request"}( + { command => [$command], node => \@nodes, bootparams => \$bootparams, noupdateinitrd => $opt{noupdateinitrd} }, + sub { push @responses, @_ }, sub { }); + } + my %kcmdline = map { $_ => $bootparams->{$_}[0]{kcmdline} } grep { $bootparams->{$_}[0]{kcmdline} } keys %$bootparams; + my @errors = map { (@{ $_->{error} || [] }, map { @{ $_->{error} || [] } } @{ $_->{node} || [] }) } @responses; + return (\%kcmdline, join("\n", @errors)); +} + +for my $name (sort keys %plugin) { + my ($kcmdline, $errors) = nodeset($name); + is($errors, '', "$name: nodeset reports no error"); + unlike($kcmdline->{n1} // '', qr/xcat\.uftp/, "$name: nodes download the root image over HTTP by default"); + + $linuximage->setAttribs({ imagename => $name }, { rootimgtransport => 'uftp' }); + ($kcmdline, $errors) = nodeset($name); + is($errors, '', "$name: an image set to uftp reports no error"); + for my $node (qw(n1 n2)) { + like($kcmdline->{$node} // '', $uftp, "$name: $node gets the listener port and the server key fingerprint"); + like($kcmdline->{$node} // '', qr{imgurl=http://\S+/rootimg\.cpio\.gz .*rootimgchecksum=yes }, "$name: $node keeps the HTTP image URL and the check"); + } + + ($kcmdline, $errors) = nodeset($name, node_addkcmdline => { n2 => 'quiet xcat.uftpfp=AA' }); + like($kcmdline->{n1} // '', $uftp, "$name: other nodes are set up when one node sets a managed argument"); + ok(!exists($kcmdline->{n2}), "$name: a node whose bootparams.addkcmdline sets a managed argument gets no boot entry"); + like($errors, qr/bootparams\.addkcmdline must not set xcat\.uftpfp/, "$name: the managed argument of the node is reported"); + + $linuximage->setAttribs({ imagename => $name }, { addkcmdline => 'console=ttyS0 xcat.uftp=1' }); + ($kcmdline, $errors) = nodeset($name); + ok(!exists($kcmdline->{n1}) && !exists($kcmdline->{n2}), "$name: no node gets a boot entry while the image sets a managed argument"); + like($errors, qr/linuximage\.addkcmdline must not set xcat\.uftp;/, "$name: the managed argument of the image is reported"); + $linuximage->setAttribs({ imagename => $name }, { addkcmdline => '' }); + + $linuximage->setAttribs({ imagename => $name }, { rootimgchecksum => 'no' }); + ($kcmdline, $errors) = nodeset($name); + ok(!exists($kcmdline->{n1}), "$name: a node gets no boot entry when its image sends over UFTP without the check"); + like($errors, qr/UFTP needs the root image checksum/, "$name: the missing check is reported"); + $linuximage->setAttribs({ imagename => $name }, { rootimgchecksum => '' }); + + # A refused node keeps its boot entry, so the kernel and initial ramdisk that it names must stay too. + my $tftp = "$fixture/tftpboot/xcat/osimage/$name"; + my %booted = map { ($_ => read_text("$tftp/$_")) } qw(kernel initrd-stateless.gz); + write_text("$dir{$name}/kernel", "kernel of $name rebuilt\n"); + write_text("$dir{$name}/initrd-stateless.gz", "initial ramdisk of $name rebuilt without the receiver\n"); + xCAT::SvrUtils->write_initrd_check_marker("$dir{$name}/initrd-stateless.gz"); + ($kcmdline, $errors) = nodeset($name); + ok(!exists($kcmdline->{n1}), "$name: a node gets no boot entry when its initial ramdisk lacks the UFTP receiver"); + like($errors, qr{\Q$dir{$name}\E/initrd-stateless\.gz is not the initial ramdisk that genimage built with the UFTP receiver}, + "$name: the initial ramdisk of the image is named"); + is(read_text("$tftp/$_"), $booted{$_}, "$name: nodes keep the $_ that they boot now") for sort keys %booted; + mark($name); + rename($xCAT::UFTP::CONFIG_FILE, "$xCAT::UFTP::CONFIG_FILE.off") or die $!; + ($kcmdline, $errors) = nodeset($name); + rename("$xCAT::UFTP::CONFIG_FILE.off", $xCAT::UFTP::CONFIG_FILE) or die $!; + like($errors, qr/UFTP listener configuration: Cannot read/, "$name: a missing listener configuration is reported"); + is(read_text("$tftp/$_"), $booted{$_}, "$name: a missing listener configuration keeps the $_ that nodes boot") for sort keys %booted; + ($kcmdline, $errors) = nodeset($name); + like($kcmdline->{n1} // '', $uftp, "$name: a rebuilt initial ramdisk with the receiver is accepted"); + + # The debian plugin has no --noupdateinitrd. + if ($name ne 'debian') { + write_text("$fixture/tftpboot/xcat/osimage/$name/initrd-stateless.gz", "initial ramdisk that nodes boot now\n"); + ($kcmdline, $errors) = nodeset($name, noupdateinitrd => 1); + like($errors, qr/is not the initial ramdisk that genimage built with the UFTP receiver/, + "$name: with --noupdateinitrd, the initial ramdisk that nodes boot decides"); + } + + $linuximage->setAttribs({ imagename => $name }, { rootimgtransport => 'tftp' }); + ($kcmdline, $errors) = nodeset($name); + ok(!exists($kcmdline->{n1}), "$name: a node gets no boot entry when its image has an unknown transport"); + like($errors, qr/The rootimgtransport attribute of the image is 'tftp', but only http and uftp are supported/, + "$name: the unknown transport is reported"); + + $linuximage->setAttribs({ imagename => $name }, { rootimgtransport => '' }); + ($kcmdline, $errors) = nodeset($name, site => { rootimgtransport => 'uftp' }); + like($kcmdline->{n1} // '', $uftp, "$name: site.rootimgtransport sets the transport of an image that does not set it"); + $linuximage->setAttribs({ imagename => $name }, { rootimgtransport => 'http' }); + ($kcmdline, $errors) = nodeset($name, site => { rootimgtransport => 'uftp' }); + unlike($kcmdline->{n1} // '', qr/xcat\.uftp/, "$name: an image set to http overrides site.rootimgtransport"); + $linuximage->setAttribs({ imagename => $name }, { rootimgtransport => '' }); +} + +{ + my ($kcmdline, $errors) = nodeset('anaconda', site => { rootimgtransport => 'uftp' }); + is($errors, '', 'site.rootimgtransport=uftp reports no error for a statelite image'); + like($kcmdline->{n3} // '', qr{imgurl=http://\S+/rootimg-statelite\.gz }, 'the statelite ramdisk node downloads its archive'); + unlike($kcmdline->{n3} // '', qr/xcat\.uftp/, 'the statelite ramdisk node does not use UFTP'); +} + +for my $name (qw(anaconda sles)) { + $linuximage->setAttribs({ imagename => $name }, { rootimgtransport => 'uftp' }); + my ($kcmdline, $errors) = nodeset($name, site => { httpmethod => 'https' }); + ok(!exists($kcmdline->{n1}), "$name: a node gets no boot entry when its image URL uses https"); + like($errors, qr/requires site\.httpmethod=http, not https/, "$name: the https image URL is reported"); + $linuximage->setAttribs({ imagename => $name }, { rootimgtransport => '' }); +} + +done_testing(); diff --git a/xCAT-test/unit/svrutils_rootimg_transport.t b/xCAT-test/unit/svrutils_rootimg_transport.t new file mode 100644 index 000000000..4917c404d --- /dev/null +++ b/xCAT-test/unit/svrutils_rootimg_transport.t @@ -0,0 +1,52 @@ +#!/usr/bin/env perl +use strict; +use warnings; +use FindBin; +use lib "$FindBin::Bin/../lib"; +use lib "$FindBin::Bin/../../perl-xCAT"; +use lib "$FindBin::Bin/../../xCAT-server/lib/perl"; +use File::Temp qw(tempdir); +use Test::More; + +my $fixture = tempdir(CLEANUP => 1); +$ENV{XCATCFG} = "SQLite:$fixture"; +require xCAT::SvrUtils; +require xCAT::Table; +require xCAT::DBobjUtils; + +sub label { + my ($image, $site) = @_; + return sprintf('image %s and site %s', map({ defined $_ ? "'$_'" : 'unset' } $image, $site)); +} + +# The image value wins, and the site value applies only when the image does not set one. +for my $case ([ undef, undef, 'http' ], [ '', undef, 'http' ], [ ' ', 'uftp', 'uftp' ], [ undef, 'uftp', 'uftp' ], + [ undef, ' UFTP ', 'uftp' ], [ 'http', 'uftp', 'http' ], [ 'Uftp', 'http', 'uftp' ], [ 'uftp', undef, 'uftp' ]) { + my ($image, $site, $expected) = @$case; + no warnings 'once'; + local %::XCATSITEVALS = (master => '192.0.2.1', defined $site ? (rootimgtransport => $site) : ()); + my ($transport, $error) = xCAT::SvrUtils->rootimg_transport($image); + is($transport, $expected, label($image, $site) . " select $expected"); + is($error, undef, label($image, $site) . ' give no error'); +} + +# Another value is refused, and the error names the attribute that holds it. +for my $case ([ 'tftp', 'uftp', qr/^The rootimgtransport attribute of the image is 'tftp'/ ], + [ undef, 'multicast', qr/^site\.rootimgtransport is 'multicast'/ ], [ '', 'yes', qr/^site\.rootimgtransport is 'yes'/ ]) { + my ($image, $site, $message) = @$case; + no warnings 'once'; + local %::XCATSITEVALS = (master => '192.0.2.1', rootimgtransport => $site); + my ($transport, $error) = xCAT::SvrUtils->rootimg_transport($image); + is($transport, undef, label($image, $site) . ' select no transport'); + like($error // '', $message, label($image, $site) . ' name the wrong value'); +} + +# The osimage object shows the attribute that the linuximage table holds. +xCAT::Table->new('osimage', -create => 1)->setAttribs({ imagename => 'image' }, + { imagetype => 'linux', provmethod => 'netboot', osvers => 'rhels9.6', osarch => 'x86_64', profile => 'compute' }); +my (undef, $error) = xCAT::Table->new('linuximage', -create => 1)->setAttribs({ imagename => 'image' }, { rootimgtransport => 'uftp' }); +is($error, undef, 'the linuximage table stores rootimgtransport'); +my %objects = xCAT::DBobjUtils->getobjdefs({ image => 'osimage' }); +is($objects{image}{rootimgtransport}, 'uftp', 'the osimage object shows rootimgtransport'); + +done_testing(); diff --git a/xCAT-test/unit/uftp_boot_arguments.t b/xCAT-test/unit/uftp_boot_arguments.t new file mode 100644 index 000000000..a94281f7e --- /dev/null +++ b/xCAT-test/unit/uftp_boot_arguments.t @@ -0,0 +1,105 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +use Cwd qw(realpath); +use File::Path qw(make_path); +use File::Slurper qw(write_binary); +use File::Temp qw(tempdir); +use FindBin; +use lib "$FindBin::Bin/../../xCAT-server/lib/perl"; +use Test::More; +use xCAT::UFTP; + +my $tmp = realpath(tempdir(CLEANUP => 1)); +my $fp = '31:94:09:9D:B1:24:3F:32:C0:B9:1A:BB:AC:F4:74:2B:80:F9:77:B2'; + +# genimage marks the ramdisk in the image directory, and nodes boot its copy in TFTP. +my $root = "$tmp/install/netboot"; +my $image = "$root/rhels9/x86_64/compute"; +my $tftp = "$tmp/tftpboot/xcat/osimage/compute"; +make_path($image, $tftp, "$tmp/etc"); +write_binary("$image/initrd-stateless.gz", 'initramfs with receiver'); +write_binary("$tftp/initrd-stateless.gz", 'initramfs with receiver'); +write_binary("$image/rootimg.cpio.gz", "root image\n"); +is(xCAT::UFTP::write_initrd_marker("$image/initrd-stateless.gz"), undef, 'the ramdisk with the receiver is marked'); +write_binary("$tmp/etc/server.fingerprint", "$fp\n"); +write_binary("$tmp/etc/listener.conf", + "listen_address = 192.0.2.1\nlisten_port = 2045\nimage_roots = $root\n" + . "fingerprint_file = $tmp/etc/server.fingerprint\n"); + +my %base = ( + rootimgdir => $image, + artifact => 'rootimg.cpio.gz', + initrd => "$tftp/initrd-stateless.gz", + marker => "$image/initrd-stateless.gz.uftp", + httpmethod => 'http', + checksum => 1, + config => "$tmp/etc/listener.conf", +); + +sub arguments { + my (%override) = @_; + return xCAT::UFTP::boot_arguments(%base, %override); +} + +my $args = "xcat.uftp=2045 xcat.uftpfp=$fp "; +is_deeply([ arguments() ], [ $args, undef ], 'nodeset passes the listener port and the server key fingerprint'); +is_deeply([ arguments(addkcmdline => 'console=ttyS0,115200 xcat.uftpx=1 uftp=true xcat.imgsha256=abc') ], + [ $args, undef ], 'other addkcmdline tokens are left to the bootloader'); +for my $token ('xcat.uftp=1', 'xcat.uftpfp=AA') { + my (undef, $refusal) = arguments(addkcmdline => "quiet $token"); + like($refusal, qr/linuximage\.addkcmdline must not set/, "a managed token in addkcmdline is refused: $token"); + (undef, $refusal) = arguments(node_addkcmdline => "quiet $token"); + like($refusal, qr/^bootparams\.addkcmdline must not set/, "a managed token in the addkcmdline of a node is refused: $token"); +} +is_deeply([ arguments(node_addkcmdline => 'console=ttyS1,115200 xcat.uftpx=1') ], [ $args, undef ], + 'other tokens in the addkcmdline of a node are left to the bootloader'); + +my (undef, $unchecked) = arguments(checksum => 0); +like($unchecked, qr/UFTP needs the root image checksum/, 'UFTP is refused for an image whose checksum is turned off'); +my (undef, $statelite) = arguments(statelite => 1); +like($statelite, qr/only the compressed root image of a stateless image/, 'UFTP is refused for a statelite image'); +my (undef, $https) = arguments(httpmethod => 'https'); +like($https, qr/requires site\.httpmethod=http, not https/, 'UFTP is refused for an HTTPS image URL'); +for my $artifact (undef, 'rootimg.gz') { + my (undef, $unknown) = arguments(artifact => $artifact); + like($unknown, qr/has no root image that UFTP can send; run packimage/, + sprintf('an image whose archive is %s is refused', $artifact // 'missing')); +} + +write_binary("$image/rootimg.cpio.gz.metainfo", 'torrent'); +my (undef, $torrent) = arguments(); +like($torrent, qr/cannot be combined with packimage --tracker/, 'UFTP is refused for a BitTorrent image'); +unlink("$image/rootimg.cpio.gz.metainfo"); + +write_binary("$tftp/initrd-stateless.gz", 'initramfs rebuilt without receiver'); +my (undef, $stale_initrd) = arguments(); +like($stale_initrd, qr/is not the initial ramdisk that genimage built with the UFTP receiver/, + 'a TFTP ramdisk that differs from the marked one is refused'); +write_binary("$tftp/initrd-stateless.gz", 'initramfs with receiver'); +xCAT::UFTP::remove_initrd_marker("$image/initrd-stateless.gz"); +my (undef, $no_marker) = arguments(); +like($no_marker, qr/was not built with the UFTP receiver/, 'a ramdisk without the receiver is refused'); +xCAT::UFTP::write_initrd_marker("$image/initrd-stateless.gz"); + +my $outside = "$tmp/images/rhels9/x86_64/compute"; +make_path($outside); +my (undef, $foreign) = arguments(rootimgdir => $outside); +like($foreign, qr/^\Q$outside\E is outside the image_roots of the UFTP listener in \Q$tmp\E\/etc\/listener\.conf$/, + 'an image that the listener does not serve is refused'); +my $spaced = "$root/rhels9/x86_64/compute two"; +make_path($spaced); +my (undef, $unsendable) = arguments(rootimgdir => $spaced); +like($unsendable, qr/has a character that a UFTP request cannot carry/, 'an image path that a request cannot name is refused'); + +my (undef, $no_config) = arguments(config => "$tmp/etc/missing.conf"); +like($no_config, qr/UFTP listener configuration: Cannot read/, 'a missing listener configuration is refused'); +write_binary("$tmp/etc/server.fingerprint", "not a fingerprint\n"); +my (undef, $bad_fp) = arguments(); +like($bad_fp, qr/does not hold a UFTP key fingerprint/, 'a malformed server fingerprint is refused'); +unlink("$tmp/etc/server.fingerprint"); +my (undef, $no_fp) = arguments(); +like($no_fp, qr/Cannot read UFTP server fingerprint/, 'a missing server fingerprint is refused'); + +done_testing(); diff --git a/xCAT-test/unit/uftp_listener.t b/xCAT-test/unit/uftp_listener.t new file mode 100644 index 000000000..f492bdf38 --- /dev/null +++ b/xCAT-test/unit/uftp_listener.t @@ -0,0 +1,359 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +use Cwd qw(realpath); +use Digest::SHA qw(sha256_hex); +use File::Path qw(make_path); +use File::Slurper qw(read_binary write_binary); +use File::Temp qw(tempdir); +use FindBin; +use Test::More; + +use lib "$FindBin::Bin/../../perl-xCAT"; +use lib "$FindBin::Bin/../../xCAT-server/lib/perl"; +use xCAT::SvrUtils; +use xCAT::UFTP; + +my $tmp = realpath(tempdir(CLEANUP => 1)); +my $root = "$tmp/install/netboot"; +my $fp = '31:94:09:9D:B1:24:3F:32:C0:B9:1A:BB:AC:F4:74:2B:80:F9:77:B2'; +make_path($root, "$tmp/log"); + +# packimage publishes the archive with its checksum, and the listener sends only such an archive. +sub publish { + my ($dir, $content, $name) = @_; + $name ||= 'rootimg.cpio.gz'; + make_path($dir); + chmod(0755, $dir); + my ($stage, $error) = xCAT::SvrUtils->rootimg_stage($dir); + die $error unless $stage; + write_binary("$stage/$name", $content); + chmod(0644, "$stage/$name"); + $error = xCAT::SvrUtils->write_rootimg_checksum("$stage/$name") // xCAT::SvrUtils->publish_rootimg("$stage/$name", "$dir/$name"); + die $error if $error; + return sha256_hex($content); +} + +my $config_seq = 0; +sub config { + my (%override) = @_; + my %settings = ( + listen_address => '192.0.2.1', + image_roots => $root, + batch_delay => 2, + transfer_timeout => 30, + job_retention => 60, + key_file => "$tmp/server.key", + fingerprint_file => "$tmp/server.fingerprint", + uftp => "$tmp/uftp", + log_dir => "$tmp/log", + %override, + ); + my $path = "$tmp/listener." . ++$config_seq . '.conf'; + write_binary($path, join('', map { "$_ = $settings{$_}\n" } sort keys %settings)); + my ($config, $error) = xCAT::UFTP::load_listener_config($path); + die $error if $error; + return $config; +} + +# The listener starts uftp in a child process, so the test records each start instead. +my (@log, @senders, @signals, $spawn_error); +my $next_pid = 4000; +sub spawn { + my ($job, $log, @command) = @_; + return (undef, $spawn_error) if $spawn_error; + my ($hostlist) = map { /^\@(.+)$/ ? $1 : () } @command; + sysseek($job->{fh}, 0, 0) or die "sysseek: $!"; + my $bytes = ''; + while (sysread($job->{fh}, my $chunk, 65536)) { + $bytes .= $chunk; + } + push @senders, { + pid => ++$next_pid, + log => $log, + command => \@command, + hostlist => $hostlist, + hosts => scalar(read_binary($hostlist)), + mode => (stat($hostlist))[2] & 07777, + bytes => $bytes, + }; + return ($next_pid, undef); +} + +sub listener { + my $config = config(@_); + @log = @senders = @signals = (); + return xCAT::UFTP::listener_state($config, workdir => tempdir(DIR => $tmp), spawn => \&spawn, + log => sub { push @log, "$_[0]: $_[1]" }, + signal => sub { push @signals, "$_[0] $_[1]" }); +} + +sub reply { + my ($state, $peer, $raw, $now) = @_; + my ($status, $body) = xCAT::UFTP::listener_reply($state, $peer, $raw, $now); + return { code => $status, body => $body, map { split /=/, $_, 2 } grep { /=/ } split /\n/, $body // '' }; +} + +sub transfer { + my ($state, $peer, $digest, $path, $now) = @_; + return reply($state, $peer, "GET /xcat-uftp/v1/transfer/$digest$path HTTP/1.0\r\nHost: test\r\n\r\n", $now); +} + +sub job_state { + my ($state, $peer, $job, $now) = @_; + return reply($state, $peer, "GET /xcat-uftp/v1/job/$job HTTP/1.0\r\n\r\n", $now); +} + +sub runtime { + my ($config, $key_fingerprint) = @_; + return xCAT::UFTP::check_listener_runtime($config, $key_fingerprint || sub { ($fp, undef) }); +} + +write_binary("$tmp/uftp", ("\0" x 64) . "UFTP version 5.0.4 (xCAT build 1) Copyright\0"); +write_binary("$tmp/uftp4", "\0UFTP version 4.10.2 Copyright\0"); +write_binary("$tmp/uftp-stock", "\0UFTP version 5.0.4 Copyright\0"); +chmod(0755, "$tmp/uftp", "$tmp/uftp4", "$tmp/uftp-stock"); + +my $checked = config(); +my ($fingerprint, $error) = runtime($checked); +like($error, qr/^UFTP server key \Q$tmp\E\/server\.key is missing; run xcat-uftp-listener --init-key$/, + 'a missing server key is refused with the command that creates it'); +write_binary("$tmp/server.key", "fixture private key\n"); +chmod(0600, "$tmp/server.key"); +($fingerprint, $error) = runtime($checked); +like($error, qr/^Cannot read UFTP server fingerprint .*; run xcat-uftp-listener --init-key$/, + 'a missing fingerprint file is refused'); +write_binary("$tmp/server.fingerprint", "$fp\n"); +($fingerprint, $error) = runtime($checked); +is($error, undef, 'the runtime check passes'); +is($fingerprint, $fp, 'the runtime check returns the fingerprint of the server key'); +is(scalar(() = glob("$tmp/log/.write-check*")), 0, 'the write check leaves no file in log_dir'); +(undef, $error) = runtime($checked, sub { (undef, "$tmp/uftp_keymgt failed for $tmp/server.key") }); +is($error, "$tmp/uftp_keymgt failed for $tmp/server.key", 'a key that uftp_keymgt cannot read is refused'); +chmod(0640, "$tmp/server.key"); +(undef, $error) = runtime($checked); +like($error, qr/must not be accessible by other users/, 'a private key that others can read is refused'); +chmod(0600, "$tmp/server.key"); +write_binary("$tmp/server.fingerprint", "00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33\n"); +(undef, $error) = runtime($checked); +like($error, qr/server\.fingerprint does not match \Q$tmp\E\/server\.key; run xcat-uftp-listener --init-key$/, + 'a fingerprint file for another key is refused'); +write_binary("$tmp/server.fingerprint", "$fp\n"); +chmod(0644, "$tmp/uftp"); +(undef, $error) = runtime($checked); +like($error, qr/UFTP sender \Q$tmp\E\/uftp is not executable; install the uftp package/, 'a sender that cannot run is refused'); +chmod(0755, "$tmp/uftp"); +(undef, $error) = runtime(config(uftp => "$tmp/uftp4")); +like($error, qr/UFTP 4\.10\.2; UFTP 5\.x is required/, 'a UFTP 4 sender is refused'); +(undef, $error) = runtime(config(uftp => "$tmp/uftp-stock")); +like($error, qr/UFTP 5\.0\.4 without the xCAT fixes/, 'a UFTP 5 sender without the xCAT build mark is refused'); +write_binary("$tmp/log-file", "not a directory\n"); +(undef, $error) = runtime(config(log_dir => "$tmp/log-file")); +is($error, "Cannot create $tmp/log-file", 'a log_dir that cannot be created is refused'); +(undef, $error) = runtime(config(log_dir => "$tmp/new/log")); +ok(!defined($error) && -d "$tmp/new/log", 'a missing log_dir is created'); + +my $image_a = "$root/rhels9/x86_64/compute"; +my $content_a = "image A generation 1\n" x 200; +my $sum_a = publish($image_a, $content_a); +my $path_a = "$image_a/rootimg.cpio.gz"; + +my $t = 1000; +my $state = listener(); +my $first = transfer($state, '192.0.2.10', $sum_a, $path_a, $t); +is($first->{code}, 200, 'a request for a published image is accepted'); +like($first->{job}, qr/^[0-9a-f]{16}$/, 'the reply names the transfer job'); +is($first->{file}, 'rootimg.cpio.gz', 'the reply names the artifact'); +is($first->{size}, length($content_a), 'the reply gives the artifact size'); +is($first->{port}, 1044, 'the reply gives the server-owned UDP port'); +is($first->{mcast}, '230.4.4.1', 'the reply gives the public multicast address'); +is($first->{server_id}, '0xC0000201', 'the reply gives the UFTP server ID'); +is($first->{server_ip}, '192.0.2.1', 'the reply gives the sending address'); +is($first->{client_ip}, '192.0.2.10', 'the reply gives the address the server saw'); +is($first->{start_in}, 2, 'the reply gives the batch delay'); +is($first->{timeout}, 30, 'the reply gives the transfer timeout'); + +my $again = transfer($state, '192.0.2.10', $sum_a, $path_a, $t + 1); +is($again->{job}, $first->{job}, 'a duplicate request joins the same job'); +is($again->{start_in}, 1, 'a later request gets the remaining batch delay'); +is(transfer($state, '192.0.2.11', $sum_a, "/$path_a", $t + 1)->{code}, 400, 'an unnormalized image path is refused'); +is(transfer($state, '192.0.2.11', $sum_a, $path_a, $t + 1)->{job}, $first->{job}, + 'a compatible request from another node joins the same job'); +my @added = map { /: job \Q$first->{job}\E: added (\S+)$/ ? $1 : () } @log; +is_deeply(\@added, [ '192.0.2.10', '192.0.2.11' ], 'the log adds each node to the job once'); +ok((grep { $_ eq "info: job $first->{job}: 192.0.2.10 asked again" } @log), 'the log reports the repeated request'); +my $pending = job_state($state, '192.0.2.10', $first->{job}, $t + 1); +is($pending->{state}, 'pending', 'a member sees the job wait for its batch'); +is($pending->{start_in}, 1, 'a waiting job reports the remaining batch delay'); + +xCAT::UFTP::run_jobs($state, $t + 1); +is(scalar(@senders), 0, 'no sender starts before the batch delay ends'); +xCAT::UFTP::run_jobs($state, $t + 2); +is(scalar(@senders), 1, 'one sender serves the coalesced requests'); +my $sent = $senders[0]; +is($sent->{hosts}, "192.0.2.10\n192.0.2.11\n", 'the closed group lists each requesting node once'); +is($sent->{mode}, 0600, 'only the listener can read the host list'); +is($sent->{bytes}, $content_a, 'the sender gets the published bytes'); +is($sent->{log}, "$tmp/log/job-$first->{job}.log", 'the sender writes the log of its job'); +is_deeply($sent->{command}, [ "$tmp/uftp", '-I', '192.0.2.1', '-U', '0xC0000201', '-p', '1044', '-M', '230.4.4.1', + '-P', '230.5.5.x', '-t', '1', '-R', '100000', '-b', '1300', '-N', '98', '-Y', 'aes256-gcm', + '-h', 'sha256', '-k', "$tmp/server.key", '-H', "\@$sent->{hostlist}", '-x', '2', '-l', + '-D', 'rootimg.cpio.gz', '/dev/fd/3' ], 'the sender gets only server-owned arguments'); +is_deeply([ xCAT::UFTP::sender_pids($state) ], [ $sent->{pid} ], 'the listener tracks the running sender'); +is(job_state($state, '192.0.2.10', $first->{job}, $t + 2)->{state}, 'running', 'a member sees the job run'); +is(job_state($state, '192.0.2.99', $first->{job}, $t + 2)->{code}, 404, 'a node outside the job cannot query it'); +my $late = transfer($state, '192.0.2.12', $sum_a, $path_a, $t + 3); +isnt($late->{job}, $first->{job}, 'a node arriving after the transfer started gets a new job'); + +xCAT::UFTP::sender_exited($state, $sent->{pid}, 0, $t + 4); +is(job_state($state, '192.0.2.10', $first->{job}, $t + 4)->{state}, 'done', 'a sender that exits with status 0 completes the job'); +ok(!-e $sent->{hostlist}, 'the host list is removed when the job finishes'); +is(scalar(xCAT::UFTP::sender_pids($state)), 0, 'a finished sender is no longer tracked'); +xCAT::UFTP::sender_exited($state, 999999, 0, $t + 4); +is(job_state($state, '192.0.2.10', $first->{job}, $t + 4)->{state}, 'done', 'the exit of an unknown process changes no job'); +xCAT::UFTP::run_jobs($state, $t + 63); +is(job_state($state, '192.0.2.10', $first->{job}, $t + 63)->{state}, 'done', 'a finished job stays for job_retention'); +xCAT::UFTP::run_jobs($state, $t + 64); +is(job_state($state, '192.0.2.10', $first->{job}, $t + 64)->{code}, 404, 'a finished job is forgotten after job_retention'); + +$state = listener(); +is(reply($state, '192.0.2.10', "GET / HTTP/1.0\r\nX-Image: $path_a\r\nReferer: /xcat-uftp/v1/transfer/$sum_a$path_a\r\n\r\n", $t)->{code}, + 404, 'an image path in a header does not select an image'); +is(transfer($state, '192.0.2.10', $sum_a, '/etc/passwd', $t)->{code}, 400, 'a request for an unrelated file is refused'); +ok((grep { /^warning: 192\.0\.2\.10: rejected request: unsupported image artifact$/ } @log), 'the log reports why a request is refused'); +write_binary("$image_a/rootimg.tar.gz", "unrelated archive\n"); +chmod(0644, "$image_a/rootimg.tar.gz"); +is(transfer($state, '192.0.2.10', sha256_hex("unrelated archive\n"), "$image_a/rootimg.tar.gz", $t)->{code}, 403, + 'an existing image file that was not published is refused'); +is(transfer($state, '192.0.2.10', $sum_a, "$root/rhels9/../../../etc/rootimg.cpio.gz", $t)->{code}, 400, 'traversal is refused'); +is(reply($state, '192.0.2.10', "GET /xcat-uftp/v1/transfer/$sum_a/install/%2e%2e/rootimg.cpio.gz HTTP/1.0\r\n\r\n", $t)->{code}, + 400, 'percent-encoded paths are refused'); +is(transfer($state, '192.0.2.10', 'f' x 64, $path_a, $t)->{code}, 403, 'an unpublished digest is refused'); +my $outside = "$tmp/outside/compute"; +my $sum_out = publish($outside, "outside\n"); +symlink($outside, "$root/escape") or die $!; +is(transfer($state, '192.0.2.10', $sum_out, "$root/escape/rootimg.cpio.gz", $t)->{code}, 403, + 'a symbolic link escaping the image roots is refused'); +is(reply($state, '192.0.2.10', "GET /xcat-uftp/v1/job/0123456789abcdef HTTP/1.0\r\n\r\n", $t)->{code}, 404, + 'an unknown job is not found'); +is(reply($state, '192.0.2.10', 'GET /x HTTP/1.0' . ('y' x 9000) . "\r\n\r\n", $t)->{code}, 413, 'an oversized request is refused'); +is(scalar(my @none = xCAT::UFTP::listener_reply($state, '192.0.2.10', "GET / HTTP/1.0\r\n", $t)), 0, + 'an incomplete request gets no reply yet'); +is(reply($state, '192.0.2.10', "POST /xcat-uftp/v1/transfer/$sum_a$path_a HTTP/1.0\r\n\r\n", $t)->{code}, 405, + 'a method other than GET is refused'); +is(scalar(keys %{ $state->{jobs} }), 0, 'refused requests create no job'); + +my $odd = "$root/-H/-x"; +my $sum_odd = publish($odd, "option-looking directory\n", 'rootimg.sfs'); +is(transfer($state, '192.0.2.10', $sum_odd, "$odd/rootimg.sfs", $t)->{code}, 200, 'an image in an option-looking directory is accepted'); +xCAT::UFTP::run_jobs($state, $t + 2); +is_deeply([ @{ $senders[0]{command} }[ -3 .. -1 ] ], [ '-D', 'rootimg.sfs', '/dev/fd/3' ], + 'the sender gets the file name of the image'); +is(scalar(grep { index($_, '/-H/-x') >= 0 } @{ $senders[0]{command} }), 0, 'no request text is passed to the sender'); +is($senders[0]{bytes}, "option-looking directory\n", 'the option-looking image is sent intact'); + +my $image_b = "$root/rhels9/x86_64/service"; +my $gen1 = "image B generation 1\n" x 100; +my $gen2 = "image B generation 2\n" x 120; +$state = listener(); +my $sum_b1 = publish($image_b, $gen1); +my $old_gen = transfer($state, '192.0.2.20', $sum_b1, "$image_b/rootimg.cpio.gz", $t); +my $sum_b2 = publish($image_b, $gen2); +is(transfer($state, '192.0.2.21', $sum_b1, "$image_b/rootimg.cpio.gz", $t)->{code}, 403, 'a repacked generation is no longer offered'); +my $new_gen = transfer($state, '192.0.2.21', $sum_b2, "$image_b/rootimg.cpio.gz", $t); +isnt($new_gen->{job}, $old_gen->{job}, 'different image generations never share a job'); +xCAT::UFTP::run_jobs($state, $t + 2); +my %by_hosts = map { $_->{hosts} => $_->{bytes} } @senders; +is($by_hosts{"192.0.2.20\n"}, $gen1, 'a queued job still sends the generation it validated'); +is($by_hosts{"192.0.2.21\n"}, $gen2, 'the new generation is sent to its own job'); + +$state = listener(); +my $failing = transfer($state, '192.0.2.30', $sum_a, $path_a, $t); +xCAT::UFTP::run_jobs($state, $t + 2); +xCAT::UFTP::sender_exited($state, $senders[-1]{pid}, 7 << 8, $t + 3); +my $failed = job_state($state, '192.0.2.30', $failing->{job}, $t + 3); +is($failed->{state}, 'failed', 'a sender failure fails the job'); +is($failed->{reason}, 'uftp exited with status 7', 'the job reports why the sender failed'); +ok((grep { $_ eq "error: job $failing->{job}: failed (uftp exited with status 7)" } @log), 'the log reports the failed job'); +my $retry = transfer($state, '192.0.2.30', $sum_a, $path_a, $t + 3); +isnt($retry->{job}, $failing->{job}, 'a retry after a failure gets a new job'); +xCAT::UFTP::run_jobs($state, $t + 5); +xCAT::UFTP::sender_exited($state, $senders[-1]{pid}, 9, $t + 6); +is(job_state($state, '192.0.2.30', $retry->{job}, $t + 6)->{reason}, 'uftp killed by signal 9', 'the job reports the signal that stopped the sender'); +my $unstarted = transfer($state, '192.0.2.31', $sum_a, $path_a, $t + 6); +$spawn_error = 'fork failed: Resource temporarily unavailable'; +xCAT::UFTP::run_jobs($state, $t + 8); +undef $spawn_error; +my $unstarted_state = job_state($state, '192.0.2.31', $unstarted->{job}, $t + 8); +is($unstarted_state->{state}, 'failed', 'a sender that cannot start fails the job'); +is($unstarted_state->{reason}, 'fork failed: Resource temporarily unavailable', 'the job reports why the sender did not start'); +is(scalar(() = glob("$state->{workdir}/*.hosts")), 0, 'no host list stays after the jobs finish'); + +$state = listener(batch_delay => 0, max_transfers => 1, max_queued_jobs => 2, max_clients_per_job => 2, transfer_timeout => 3); +my $c_image = "$root/rhels9/x86_64/login"; +my $sum_c = publish($c_image, "image C\n"); +my $blocking = transfer($state, '192.0.2.40', $sum_a, $path_a, $t); +xCAT::UFTP::run_jobs($state, $t); +is(job_state($state, '192.0.2.40', $blocking->{job}, $t)->{state}, 'running', 'a zero batch delay starts the transfer at once'); +my $waiting = transfer($state, '192.0.2.41', $sum_b2, "$image_b/rootimg.cpio.gz", $t); +xCAT::UFTP::run_jobs($state, $t + 1); +is(job_state($state, '192.0.2.41', $waiting->{job}, $t + 1)->{state}, 'pending', 'a second image waits while the transfer limit is reached'); +is(transfer($state, '192.0.2.42', $sum_c, "$c_image/rootimg.cpio.gz", $t + 1)->{code}, 503, 'the queue limit refuses further images'); +is(transfer($state, '192.0.2.42', $sum_b2, "$image_b/rootimg.cpio.gz", $t + 1)->{job}, $waiting->{job}, 'a node joins the waiting job'); +my $full = transfer($state, '192.0.2.43', $sum_b2, "$image_b/rootimg.cpio.gz", $t + 1); +is($full->{code}, 503, 'the per-job client limit refuses further nodes'); +is($full->{error}, 'transfer is full', 'the reply says the job is full'); +xCAT::UFTP::run_jobs($state, $t + 2); +is_deeply(\@signals, [], 'a sender within its timeout keeps running'); +xCAT::UFTP::run_jobs($state, $t + 3); +is_deeply(\@signals, ["TERM $senders[0]{pid}"], 'a sender that exceeds transfer_timeout is asked to stop'); +xCAT::UFTP::sender_exited($state, $senders[0]{pid}, 15, $t + 3); +my $timed_out = job_state($state, '192.0.2.40', $blocking->{job}, $t + 3); +is($timed_out->{state}, 'failed', 'a transfer that exceeds its timeout fails'); +is($timed_out->{reason}, 'transfer exceeded 3s', 'the timeout is reported'); +xCAT::UFTP::run_jobs($state, $t + 3); +is(job_state($state, '192.0.2.41', $waiting->{job}, $t + 3)->{state}, 'running', 'the waiting transfer starts when the slot frees'); +is($senders[1]{hosts}, "192.0.2.41\n192.0.2.42\n", 'the waiting transfer sends to the nodes that joined it'); +@signals = (); +xCAT::UFTP::run_jobs($state, $t + 6); +xCAT::UFTP::run_jobs($state, $t + 10); +is_deeply(\@signals, ["TERM $senders[1]{pid}"], 'a sender gets the kill grace time after it is asked to stop'); +xCAT::UFTP::run_jobs($state, $t + 11); +is_deeply(\@signals, [ "TERM $senders[1]{pid}", "KILL $senders[1]{pid}" ], 'a sender that ignores the request to stop is killed'); +xCAT::UFTP::sender_exited($state, $senders[1]{pid}, 9, $t + 11); +is(job_state($state, '192.0.2.41', $waiting->{job}, $t + 11)->{reason}, 'transfer exceeded 3s', 'a killed sender reports the timeout'); + +$state = listener(batch_delay => 0, max_transfers => 1, transfer_timeout => 3); +transfer($state, '192.0.2.50', $sum_a, $path_a, $t); +xCAT::UFTP::run_jobs($state, $t); +my $starved = transfer($state, '192.0.2.51', $sum_c, "$c_image/rootimg.cpio.gz", $t); +xCAT::UFTP::run_jobs($state, $t + 3); +is(job_state($state, '192.0.2.51', $starved->{job}, $t + 3)->{state}, 'pending', 'a waiting job waits up to transfer_timeout for a slot'); +xCAT::UFTP::run_jobs($state, $t + 4); +my $starved_state = job_state($state, '192.0.2.51', $starved->{job}, $t + 4); +is($starved_state->{state}, 'failed', 'a job that gets no slot within transfer_timeout fails'); +is($starved_state->{reason}, 'no transfer slot became free', 'the job reports that no slot became free'); + +$state = listener(); +my $queued = transfer($state, '192.0.2.60', $sum_a, $path_a, $t); +my $sending = transfer($state, '192.0.2.61', $sum_c, "$c_image/rootimg.cpio.gz", $t - 2); +xCAT::UFTP::run_jobs($state, $t); +xCAT::UFTP::sender_exited($state, $senders[0]{pid}, 15, $t, 'listener stopped'); +xCAT::UFTP::stop_jobs($state, $t); +is(job_state($state, '192.0.2.61', $sending->{job}, $t)->{reason}, 'listener stopped', 'a sender stopped with the listener fails its job'); +my $stopped = job_state($state, '192.0.2.60', $queued->{job}, $t); +is($stopped->{state}, 'failed', 'a queued job fails when the listener stops'); +is($stopped->{reason}, 'listener stopped', 'the queued job reports that the listener stopped'); + +$state = listener(max_connections => 2, allowed_networks => '192.0.2.8/30'); +is_deeply([ xCAT::UFTP::connection_refusal($state, 1, '192.0.2.9') ], [], 'an address inside allowed_networks is accepted'); +is_deeply([ xCAT::UFTP::connection_refusal($state, 2, '192.0.2.9') ], [ 503, "error=too many connections\n" ], + 'a connection beyond max_connections is refused'); +is_deeply([ xCAT::UFTP::connection_refusal($state, 0, '192.0.2.12') ], [ 403, "error=client address is not allowed\n" ], + 'an address outside allowed_networks is refused'); +is_deeply(\@log, ['warning: 192.0.2.12: address is not in allowed_networks'], 'the log names the refused address'); +$state = listener(); +is_deeply([ xCAT::UFTP::connection_refusal($state, 0, '198.51.100.7') ], [], 'any address is accepted without allowed_networks'); + +done_testing(); diff --git a/xCAT-test/unit/uftp_receiver.t b/xCAT-test/unit/uftp_receiver.t new file mode 100644 index 000000000..fdf069ca3 --- /dev/null +++ b/xCAT-test/unit/uftp_receiver.t @@ -0,0 +1,179 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +use Digest::SHA qw(sha256_hex); +use Errno (); +use File::Path qw(make_path); +use File::Slurper qw(read_binary write_binary); +use File::Temp qw(tempdir); +use FindBin; +use lib "$FindBin::Bin/../../xCAT-server/lib/perl"; +use Test::More; + +our $fail_write; +BEGIN { + *CORE::GLOBAL::syswrite = sub { + if ($fail_write) { + $! = Errno::ENOSPC(); + return undef; + } + return CORE::syswrite($_[0], $_[1], $_[2] // length($_[1]), $_[3] // 0); + }; +} +use xCAT::UFTP; + +my $tmp = tempdir(CLEANUP => 1); + +sub entries { + my ($dir) = @_; + opendir(my $dh, $dir) or return (); + my @names = sort grep { $_ ne '.' && $_ ne '..' } readdir($dh); + closedir($dh); + return @names; +} + +# genimage records the initial ramdisk it built with the receiver, and nodeset checks the copy that nodes boot. +my $initrd = "$tmp/initrd-stateless.gz"; +write_binary($initrd, 'initramfs with receiver'); +is(xCAT::UFTP::verify_initrd_marker($initrd) // '', + "$initrd was not built with the UFTP receiver; install the uftp package in the image and run genimage again", + 'an initial ramdisk without a marker cannot receive over UFTP'); +is(xCAT::UFTP::write_initrd_marker($initrd), undef, 'genimage records a ramdisk with the receiver'); +is(scalar(read_binary("$initrd.uftp")), 'sha256=' . sha256_hex('initramfs with receiver') . "\n", + 'the marker holds the SHA-256 of the ramdisk'); +is((stat("$initrd.uftp"))[2] & 07777, 0644, 'the marker is readable'); +is(xCAT::UFTP::verify_initrd_marker($initrd), undef, 'the recorded ramdisk is accepted'); +write_binary("$tmp/tftp-copy.gz", 'initramfs with receiver'); +is(xCAT::UFTP::verify_initrd_marker("$tmp/tftp-copy.gz", "$initrd.uftp"), undef, + 'an identical copy of the ramdisk is accepted under the marker of the original'); +write_binary("$tmp/other-initrd.gz", 'initramfs regenerated without uftp'); +like(xCAT::UFTP::verify_initrd_marker("$tmp/other-initrd.gz", "$initrd.uftp"), + qr/is not the initial ramdisk that genimage built with the UFTP receiver/, + 'another ramdisk is not accepted under an old marker'); +write_binary("$tmp/broken.uftp", "format=1\n"); +like(xCAT::UFTP::verify_initrd_marker($initrd, "$tmp/broken.uftp"), qr/is not a UFTP receiver marker/, + 'a marker in another format is refused'); +is(xCAT::UFTP::remove_initrd_marker($initrd), undef, 'the marker is removed'); +is(xCAT::UFTP::remove_initrd_marker($initrd), undef, 'removing a missing marker succeeds'); +like(xCAT::UFTP::verify_initrd_marker($initrd), qr/was not built with the UFTP receiver/, + 'a ramdisk rebuilt without the receiver loses the marker'); + +my $atomic = "$tmp/atomic"; +make_path($atomic); +write_binary("$atomic/server.fingerprint", "old fingerprint\n"); +{ + local $fail_write = 1; + like(xCAT::UFTP::write_file_atomic("$atomic/server.fingerprint", "new fingerprint\n", 0644), + qr/Cannot write/, 'a failed write is reported'); +} +is(scalar(read_binary("$atomic/server.fingerprint")), "old fingerprint\n", 'a failed write keeps the existing file'); +{ + no warnings qw(redefine once); + local *IO::Handle::sync = sub { $! = Errno::EIO(); return 0 }; + like(xCAT::UFTP::write_file_atomic("$atomic/server.fingerprint", "new fingerprint\n", 0644), + qr/Cannot flush/, 'a failed flush is reported'); +} +is(scalar(read_binary("$atomic/server.fingerprint")), "old fingerprint\n", 'a failed flush keeps the existing file'); +is_deeply([ entries($atomic) ], ['server.fingerprint'], 'failed writes leave no temporary file'); +is(xCAT::UFTP::write_file_atomic("$atomic/server.fingerprint", "new fingerprint\n", 0644), undef, + 'a complete write replaces the file'); +is(scalar(read_binary("$atomic/server.fingerprint")), "new fingerprint\n", 'the replacement has the new content'); +is((stat("$atomic/server.fingerprint"))[2] & 07777, 0644, 'the replacement has the requested mode'); + +sub elf { + my ($path, $class, $data, $machine) = @_; + my $ident = "\x7fELF" . chr($class) . chr($data) . "\x01" . ("\0" x 9); + my $type = $data == 2 ? pack('n', 2) : pack('v', 2); + my $mach = $data == 2 ? pack('n', $machine) : pack('v', $machine); + write_binary($path, $ident . $type . $mach . ("\0" x 44)); + chmod(0755, $path); +} + +elf("$tmp/x86_64", 2, 1, 62); +elf("$tmp/ppc64le", 2, 1, 21); +elf("$tmp/ppc64", 2, 2, 21); +elf("$tmp/aarch64", 2, 1, 183); +elf("$tmp/riscv64", 2, 1, 243); +is(xCAT::UFTP::elf_arch_error("$tmp/$_", $_), undef, "a $_ receiver matches a $_ image") + for qw(x86_64 ppc64le ppc64 aarch64 riscv64); +like(xCAT::UFTP::elf_arch_error("$tmp/x86_64", 'ppc64le'), qr/built for another architecture than ppc64le/, + 'a management node binary is refused for another image architecture'); +like(xCAT::UFTP::elf_arch_error("$tmp/ppc64le", 'ppc64'), qr/another architecture/, + 'byte order distinguishes ppc64le from ppc64'); +is(xCAT::UFTP::elf_arch_error("$tmp/ppc64le", 'ppc64el'), undef, 'a ppc64le receiver matches an Ubuntu ppc64el image'); +like(xCAT::UFTP::elf_arch_error("$tmp/ppc64", 'ppc64el'), qr/another architecture/, + 'a big-endian receiver is refused for an Ubuntu ppc64el image'); +like(xCAT::UFTP::elf_arch_error("$tmp/aarch64", 'x86_64'), qr/another architecture/, + 'an aarch64 receiver is refused for an x86_64 image'); +write_binary("$tmp/script", "#!/bin/sh\n"); +chmod(0755, "$tmp/script"); +like(xCAT::UFTP::elf_arch_error("$tmp/script", 'x86_64'), qr/not an ELF executable/, + 'a script is not accepted as a receiver'); +like(xCAT::UFTP::elf_arch_error("$tmp/x86_64", 'sparc'), qr/not supported for architecture sparc/, + 'unknown architectures are refused'); + +sub receiver_image { + my ($name, $class, $data, $machine, $version) = @_; + my $root = "$tmp/receiver-$name"; + make_path("$root/usr/bin"); + elf("$root/usr/bin/uftpd", $class, $data, $machine); + open(my $fh, '>>:raw', "$root/usr/bin/uftpd") or die $!; + print {$fh} "\0UFTP version $version Copyright\0"; + close($fh); + return $root; +} +is_deeply([ xCAT::UFTP::usable_receiver(receiver_image('ok', 2, 1, 62, '5.0.3 (xCAT build 1)'), 'x86_64') ], + [ '/usr/bin/uftpd', undef ], 'a UFTP 5 receiver of xcat-dep for the image architecture is used'); +my ($stock_receiver, $stock_warning) = xCAT::UFTP::usable_receiver(receiver_image('stock', 2, 1, 62, '5.0.3'), 'x86_64'); +ok(!defined($stock_receiver), 'a UFTP 5 receiver without the xCAT build mark is left out'); +like($stock_warning, qr/UFTP 5\.0\.3 without the xCAT fixes; use the uftp package of xcat-dep; the initial ramdisk will not/, + 'a UFTP 5 receiver without the xCAT build mark gets a warning'); +my ($none_receiver, $none_warning) = xCAT::UFTP::usable_receiver("$tmp/receiver-absent", 'x86_64'); +ok(!defined($none_receiver) && !defined($none_warning), 'an image without uftpd gets no receiver and no warning'); +my ($old_receiver, $old_warning) = xCAT::UFTP::usable_receiver(receiver_image('old', 2, 1, 62, '4.10.2'), 'x86_64'); +ok(!defined($old_receiver), 'a UFTP 4 receiver is left out'); +like($old_warning, qr/UFTP 5\.x is required; the initial ramdisk will not receive the root image over UFTP/, + 'a UFTP 4 receiver gets a warning instead of failing the image build'); +is_deeply([ xCAT::UFTP::usable_receiver(receiver_image('ppc64el', 2, 1, 21, '5.0.3 (xCAT build 1)'), 'ppc64el') ], + [ '/usr/bin/uftpd', undef ], 'a ppc64le receiver of xcat-dep is used for an Ubuntu ppc64el image'); +my ($foreign_receiver, $foreign_warning) = xCAT::UFTP::usable_receiver(receiver_image('foreign', 2, 1, 183, '5.0.3 (xCAT build 1)'), 'x86_64'); +ok(!defined($foreign_receiver), 'a receiver for another architecture is left out'); +like($foreign_warning, qr/another architecture than x86_64/, 'a foreign receiver gets a warning'); + +write_binary("$tmp/uftp5", ("\0" x 70000) . "UFTP version 5.0.3 (xCAT build 1) Copyright (C) 2001-2023\0"); +chmod(0755, "$tmp/uftp5"); +write_binary("$tmp/uftp5-stock", "\0UFTP version 5.0.3 Copyright (C) 2001-2023\0"); +chmod(0755, "$tmp/uftp5-stock"); +write_binary("$tmp/uftp4", "\0UFTP version 4.10.2 Copyright\0"); +chmod(0755, "$tmp/uftp4"); +write_binary("$tmp/uftp-none", "\0no version here\0"); +chmod(0755, "$tmp/uftp-none"); +is(xCAT::UFTP::uftp_version_error("$tmp/uftp5"), undef, 'UFTP 5 binaries with the xCAT build mark are accepted'); +like(xCAT::UFTP::uftp_version_error("$tmp/uftp5-stock"), qr/is UFTP 5\.0\.3 without the xCAT fixes/, + 'UFTP 5 binaries without the xCAT build mark are refused'); +like(xCAT::UFTP::uftp_version_error("$tmp/uftp4"), qr/is UFTP 4\.10\.2; UFTP 5\.x is required/, + 'UFTP 4 binaries are refused'); +like(xCAT::UFTP::uftp_version_error("$tmp/uftp-none"), qr/Cannot find the UFTP version/, + 'binaries without a version string are refused'); + +my @listing = ( + 'drwxr-xr-x 2 root root 0 Jan 1 00:00 usr/bin', + '-rwxr-xr-x 1 root root 180000 Jan 1 00:00 usr/bin/uftpd', + '-rwxr-xr-x 1 root root 40000 Jan 1 00:00 usr/bin/sha256sum', + '-rw-r--r-- 1 root root 12000 Jan 1 00:00 lib/xcat-rootimg-fetch.sh', +); +is_deeply([ xCAT::UFTP::initrd_missing_files(\@listing) ], [], 'a complete initramfs listing passes'); +my @sbin = @listing; +$sbin[1] =~ s{usr/bin/uftpd}{usr/sbin/uftpd}; +is_deeply([ xCAT::UFTP::initrd_missing_files(\@sbin) ], [], 'a receiver in usr/sbin is accepted'); +my @usrmerge = @listing; +$usrmerge[3] =~ s{lib/xcat-rootimg-fetch\.sh}{usr/lib/xcat-rootimg-fetch.sh}; +push @usrmerge, 'lrwxrwxrwx 1 root root 7 Jan 1 00:00 lib -> usr/lib'; +is_deeply([ xCAT::UFTP::initrd_missing_files(\@usrmerge) ], [], 'a helper under usr/lib in a usrmerged initramfs is accepted'); +is_deeply([ xCAT::UFTP::initrd_missing_files([ @listing[0, 2, 3] ]) ], ['uftpd'], + 'a missing receiver is reported'); +is_deeply([ xCAT::UFTP::initrd_missing_files([ @listing[0, 1] ]) ], + [ 'sha256sum', 'xcat-rootimg-fetch.sh' ], 'missing verification files are reported'); + +done_testing(); diff --git a/xCAT-test/unit/uftp_request.t b/xCAT-test/unit/uftp_request.t new file mode 100644 index 000000000..f6c7394e1 --- /dev/null +++ b/xCAT-test/unit/uftp_request.t @@ -0,0 +1,273 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +use Cwd qw(realpath); +use Digest::SHA qw(sha256_hex); +use File::Path qw(make_path); +use File::Slurper qw(write_binary); +use File::Temp qw(tempdir); +use FindBin; +use lib "$FindBin::Bin/../../perl-xCAT"; +use lib "$FindBin::Bin/../../xCAT-server/lib/perl"; +use Test::More; +use xCAT::SvrUtils; +use xCAT::UFTP; + +# The image checks refuse files that other users can write. +umask(022); +my $tmp = realpath(tempdir(CLEANUP => 1)); + +# packimage publishes the archive with its checksum, and the listener sends only such an archive. +sub publish { + my ($dir, $name, $content) = @_; + make_path($dir); + chmod(0755, $dir); + my ($stage, $error) = xCAT::SvrUtils->rootimg_stage($dir); + die $error unless $stage; + write_binary("$stage/$name", $content); + $error = xCAT::SvrUtils->write_rootimg_checksum("$stage/$name") // xCAT::SvrUtils->publish_rootimg("$stage/$name", "$dir/$name"); + die $error if $error; + return sha256_hex($content); +} + +my $sha = sha256_hex('x'); +my $request = xCAT::UFTP::parse_http_request( + "GET /xcat-uftp/v1/transfer/$sha/install/netboot/img/rootimg.cpio.gz HTTP/1.1\r\n" + . "Host: 192.0.2.1:1045\r\nUser-Agent: curl/8\r\n\r\n"); +is_deeply($request, { method => 'GET', target => "/xcat-uftp/v1/transfer/$sha/install/netboot/img/rootimg.cpio.gz" }, + 'the request target is parsed from the request line'); +is_deeply(xCAT::UFTP::parse_http_request("GET /a HTTP/1.0\n\n"), { method => 'GET', target => '/a' }, + 'bare line feeds are accepted'); +ok(xCAT::UFTP::parse_http_request("GET /a HTTP/1.0\r\nHost: x\r\n")->{incomplete}, + 'a request without the end of its headers is incomplete'); +my $header_only = xCAT::UFTP::parse_http_request( + "GET / HTTP/1.0\r\nX-Image: /install/netboot/img/rootimg.cpio.gz\r\n\r\n"); +is($header_only->{target}, '/', 'an image path in a header is not the request target'); +for my $case ( + [ "POST /a HTTP/1.0\r\n\r\n", 405 ], + [ "GET /a HTTP/2.0\r\n\r\n", 400 ], + [ "GET /a HTTP/1.0\r\n\r\n", 400 ], + [ "GET /a HTTP/1.0 extra\r\n\r\n", 400 ], + [ "GET /install/%2e%2e/etc/rootimg.cpio.gz HTTP/1.0\r\n\r\n", 400 ], + [ "GET /a?image=/install/x/rootimg.cpio.gz HTTP/1.0\r\n\r\n", 400 ], + [ "GET /a#frag HTTP/1.0\r\n\r\n", 400 ], + [ "GET /a\tb HTTP/1.0\r\n\r\n", 400 ], + [ "GET /a HTTP/1.0\r\nno colon here\r\n\r\n", 400 ], + [ "GET /a HTTP/1.0\r\n" . ("X: y\r\n" x 65) . "\r\n", 400 ], +) { + (my $label = $case->[0]) =~ s/[\r\n].*//s; + is(xCAT::UFTP::parse_http_request($case->[0])->{status}, $case->[1], "'$label' is answered with $case->[1]"); +} + +is(xCAT::UFTP::normalize_image_path('//install//netboot/rhels9/x86_64/compute/'), + '/install/netboot/rhels9/x86_64/compute', 'repeated and trailing slashes are normalized'); +for my $path ('install/x', '/install/../etc', '/install/./x', '/install/.hidden/x', '/a b', + '/install/x%2e', '/install/x?y', '/' . ('a/' x 70), '/' . ('a' x 300)) { + is(xCAT::UFTP::normalize_image_path($path), undef, "image path '$path' is refused"); +} + +my $prefix = "/xcat-uftp/v1/transfer/$sha"; +my ($digest, $path, $error) = xCAT::UFTP::parse_transfer_target("$prefix/install/netboot/img/rootimg.tar.xz"); +is_deeply([ $digest, $path, $error ], [ $sha, '/install/netboot/img/rootimg.tar.xz', undef ], + 'a transfer target yields the digest and the image path'); +for my $target ( + "$prefix/install/netboot/img/../../../etc/rootimg.cpio.gz", + "$prefix//install/netboot/img/rootimg.cpio.gz", + "$prefix/install/netboot/img/rootimg.cpio.gz/", + "$prefix/install/netboot/img/.hidden/rootimg.cpio.gz", + "$prefix/etc/passwd", + "$prefix/install/netboot/img/rootimg.gz", + '/xcat-uftp/v1/transfer/' . ('A' x 64) . '/install/netboot/img/rootimg.cpio.gz', + '/xcat-uftp/v1/transfer/' . ('a' x 63) . '/install/netboot/img/rootimg.cpio.gz', + '/xcat-uftp/v2/transfer/' . $sha . '/install/netboot/img/rootimg.cpio.gz', +) { + my (undef, undef, $target_error) = xCAT::UFTP::parse_transfer_target($target); + ok($target_error, "transfer target '$target' is refused"); +} +is_deeply([ xCAT::UFTP::parse_job_target('/xcat-uftp/v1/job/0123456789abcdef') ], [ '0123456789abcdef', undef ], + 'a job target yields the job identifier'); +ok((xCAT::UFTP::parse_job_target('/xcat-uftp/v1/job/../../x'))[1], 'a malformed job target is refused'); + +my $root = "$tmp/install/netboot"; +my $image_dir = "$root/rhels9/x86_64/compute"; +my $digest_published = publish($image_dir, 'rootimg.cpio.gz', "published image\n"); +my @roots = (realpath($root)); + +my ($image, $resolve_error) = xCAT::UFTP::resolve_artifact(\@roots, "$image_dir/rootimg.cpio.gz", $digest_published); +is($resolve_error, undef, 'a published image under an image root resolves'); +is($image->{name}, 'rootimg.cpio.gz', 'the transfer keeps the archive name'); +is($image->{size}, length("published image\n"), 'the resolved image has the published size'); +my @published = stat("$image_dir/rootimg.cpio.gz"); +is("$image->{dev}:$image->{ino}", "$published[0]:$published[1]", 'the job is keyed to the published inode'); + +# A batch keeps the bytes it opened when packimage publishes the next archive. +my $next = publish($image_dir, 'rootimg.cpio.gz', "next image\n"); +my $bytes = do { local $/; my $fh = $image->{fh}; <$fh> }; +is($bytes, "published image\n", 'the open handle reads the archive of its batch after a new publication'); +close($image->{fh}); + +sub refused { + my ($roots, $path, $digest, $pattern, $label) = @_; + my ($resolved, $refusal) = xCAT::UFTP::resolve_artifact($roots, $path, $digest); + close($resolved->{fh}) if $resolved; + like($refusal // '', $pattern, $label); +} + +refused(\@roots, "$image_dir/rootimg.cpio.gz", $digest_published, + qr/not the published one/, 'the digest of a replaced archive is refused'); +refused(\@roots, "$image_dir/rootimg.cpio.gz", sha256_hex('another generation'), + qr/not the published one/, 'a digest that the checksum file does not list is refused'); +write_binary("$image_dir/rootimg.cpio.gz.sha256", "$next rootimg.cpio.gz\n$digest_published rootimg.cpio.gz\n"); +my ($during) = xCAT::UFTP::resolve_artifact(\@roots, "$image_dir/rootimg.cpio.gz", $digest_published); +ok($during, 'while packimage replaces the archive, the previous digest in the checksum file is accepted'); +close($during->{fh}) if $during; +write_binary("$image_dir/rootimg.cpio.gz.sha256", "$next rootimg.cpio.xz\n"); +refused(\@roots, "$image_dir/rootimg.cpio.gz", $next, qr/not the published one/, + 'a checksum line for another archive name is refused'); +write_binary("$image_dir/rootimg.cpio.gz.sha256", "$next rootimg.cpio.gz\n"); + +write_binary("$image_dir/rootimg.cpio.xz", "unrelated existing file\n"); +refused(\@roots, "$image_dir/rootimg.cpio.xz", sha256_hex("unrelated existing file\n"), + qr/image is not published: .*rootimg\.cpio\.xz\.sha256 does not exist/, 'an archive without a checksum file is refused'); + +my $outside = "$tmp/srv/images/compute"; +my $outside_digest = publish($outside, 'rootimg.cpio.gz', "outside image\n"); +refused(\@roots, "$outside/rootimg.cpio.gz", $outside_digest, + qr/outside the configured image roots/, 'a published image outside the image roots is refused'); +my $sibling = "$tmp/install/netboot2/compute"; +my $sibling_digest = publish($sibling, 'rootimg.cpio.gz', "sibling image\n"); +refused(\@roots, "$sibling/rootimg.cpio.gz", $sibling_digest, + qr/outside the configured image roots/, 'a directory sharing a name prefix with a root is refused'); +my ($configured) = xCAT::UFTP::resolve_artifact([ @roots, realpath("$tmp/srv/images") ], + "$outside/rootimg.cpio.gz", $outside_digest); +ok($configured, 'an explicitly configured image root outside /install is accepted'); +close($configured->{fh}) if $configured; + +symlink($outside, "$root/escape") or die $!; +refused(\@roots, "$root/escape/rootimg.cpio.gz", $outside_digest, + qr/outside the configured image roots/, 'a symbolic link that escapes the image roots is refused'); +symlink($image_dir, "$root/alias") or die $!; +my ($alias) = xCAT::UFTP::resolve_artifact(\@roots, "$root/alias/rootimg.cpio.gz", $next); +my @current = stat("$image_dir/rootimg.cpio.gz"); +ok($alias && $alias->{ino} == $current[1], 'a symbolic link that stays inside an image root is resolved'); +close($alias->{fh}) if $alias; + +my $swapped = "$root/swapped"; +my $swapped_digest = publish($swapped, 'rootimg.cpio.gz', "swapped archive\n"); +write_binary("$tmp/secret", "secret readable by the service\n"); +unlink("$swapped/rootimg.cpio.gz") or die $!; +symlink("$tmp/secret", "$swapped/rootimg.cpio.gz") or die $!; +refused(\@roots, "$swapped/rootimg.cpio.gz", $swapped_digest, + qr/is a symbolic link/, 'an archive replaced by a symbolic link is refused'); + +my $linked_sums = "$root/linked-sums"; +my $linked_digest = publish($linked_sums, 'rootimg.cpio.gz', "linked checksum\n"); +rename("$linked_sums/rootimg.cpio.gz.sha256", "$tmp/moved.sha256") or die $!; +symlink("$tmp/moved.sha256", "$linked_sums/rootimg.cpio.gz.sha256") or die $!; +refused(\@roots, "$linked_sums/rootimg.cpio.gz", $linked_digest, + qr/is a symbolic link/, 'a checksum file replaced by a symbolic link is refused'); + +my $writable = "$root/writable"; +my $writable_digest = publish($writable, 'rootimg.cpio.gz', "writable\n"); +chmod(0777, $writable) or die $!; +refused(\@roots, "$writable/rootimg.cpio.gz", $writable_digest, + qr/writable by other users/, 'an image directory writable by others is refused'); +chmod(0755, $writable) or die $!; +chmod(0666, "$writable/rootimg.cpio.gz") or die $!; +refused(\@roots, "$writable/rootimg.cpio.gz", $writable_digest, + qr/writable by other users/, 'an archive writable by others is refused'); + +my $empty = "$root/empty"; +make_path($empty); +chmod(0755, $empty) or die $!; +write_binary("$empty/rootimg.cpio.gz", ''); +write_binary("$empty/rootimg.cpio.gz.sha256", sha256_hex('') . " rootimg.cpio.gz\n"); +refused(\@roots, "$empty/rootimg.cpio.gz", sha256_hex(''), qr/is empty/, 'an empty archive is refused'); + +refused(\@roots, "$root/missing/rootimg.cpio.gz", $next, qr/does not exist/, + 'a missing image directory is refused'); + +my $config_file = "$tmp/listener.conf"; +sub config { + my ($text) = @_; + write_binary($config_file, $text); + return xCAT::UFTP::load_listener_config($config_file); +} +my ($config, $config_error) = config("listen_address = 192.0.2.1\nimage_roots = $root\n"); +is($config_error, undef, 'a minimal configuration loads'); +is($config->{listen_port}, 1045, 'the listener port defaults to 1045'); +is($config->{udp_port}, 1044, 'the UFTP port defaults to 1044'); +is($config->{batch_delay}, 30, 'the batch delay defaults to 30 seconds'); +is($config->{block_size}, 1300, 'the block size defaults to one that fits an MTU of 1500'); +is($config->{multicast_interface}, '192.0.2.1', 'multicast is sent from the listen address by default'); +is($config->{server_id}, '0xC0000201', 'the UFTP server ID is the sending address'); +is_deeply($config->{image_roots}, \@roots, 'image roots are canonical paths'); +($config) = config("listen_address = 192.0.2.1\nimage_roots = $root\nbatch_delay = 0\n"); +is($config->{batch_delay}, 0, 'a zero batch delay is supported'); +($config, $config_error) = config("listen_address = 192.0.2.1\nimage_roots = $root\nmax_transfers =\nblock_size =\n" + . "uftp =\nallowed_networks =\nmulticast_interface =\n"); +is($config_error, undef, 'a configuration with empty values loads'); +is($config->{max_transfers}, 4, 'an empty max_transfers takes its default'); +is($config->{block_size}, 1300, 'an empty block_size takes its default'); +is($config->{uftp}, '/usr/bin/uftp', 'an empty uftp path takes its default'); +ok(xCAT::UFTP::address_allowed($config->{allowed_networks}, '198.51.100.8'), 'empty allowed_networks allow any address'); +is($config->{multicast_interface}, '192.0.2.1', 'an empty multicast_interface sends from the listen address'); +for my $size (512, 8000, 8800) { + my ($sized, $sized_error) = config("listen_address = 192.0.2.1\nimage_roots = $root\nblock_size = $size\n"); + is($sized_error, undef, "a block size of $size is accepted"); + is($sized->{block_size}, $size, "the block size of $size is kept"); +} +($config) = config("listen_address = 192.0.2.1\nimage_roots = $root, $tmp/srv/images\n" + . "allowed_networks = 192.0.2.0/24, 198.51.100.7/32\nmulticast_interface = 192.0.2.9\n"); +ok(xCAT::UFTP::address_allowed($config->{allowed_networks}, '192.0.2.200'), 'an address inside allowed_networks is allowed'); +ok(xCAT::UFTP::address_allowed($config->{allowed_networks}, '198.51.100.7'), 'a host network is allowed'); +ok(!xCAT::UFTP::address_allowed($config->{allowed_networks}, '198.51.100.8'), 'other addresses are refused'); +is($config->{server_id}, '0xC0000209', 'a separate multicast interface sets the server ID'); +is(scalar(@{ $config->{image_roots} }), 2, 'several image roots are accepted'); + +for my $case ( + [ "image_roots = $root\n", qr/listen_address is required/ ], + [ "listen_address =\nimage_roots = $root\n", qr/listen_address is required/ ], + [ "listen_address = ::1\nimage_roots = $root\n", qr/listen_address must be an IPv4 address/ ], + [ "listen_address = 192.0.2.1\nimage_roots = $root\nlisten_port = 0\n", qr/between 1 and 65535/ ], + [ "listen_address = 192.0.2.1\nimage_roots = $root\nlisten_port = 65536\n", qr/between 1 and 65535/ ], + [ "listen_address = 192.0.2.1\nimage_roots = $root\nudp_port = 10a\n", qr/udp_port must be an integer/ ], + [ "listen_address = 192.0.2.1\nimage_roots = $root\nbatch_delay = -1\n", qr/batch_delay must be an integer/ ], + [ "listen_address = 192.0.2.1\nimage_roots = $root\nbatch_delay = 3601\n", qr/between 0 and 3600/ ], + [ "listen_address = 192.0.2.1\nimage_roots = $root\nbatch_delay = 030\n", qr/must be an integer/ ], + [ "listen_address = 192.0.2.1\nimage_roots = $root\ntransfer_timeout = 0\n", qr/between 1 and 86400/ ], + [ "listen_address = 192.0.2.1\nimage_roots = $root\nblock_size = 511\n", qr/block_size must be between 512 and 8800/ ], + [ "listen_address = 192.0.2.1\nimage_roots = $root\nblock_size = 8801\n", qr/block_size must be between 512 and 8800/ ], + [ "listen_address = 192.0.2.1\nimage_roots = $root\npublic_multicast = 10.0.0.1\n", qr/multicast address/ ], + [ "listen_address = 192.0.2.1\nimage_roots = $root\nencryption = none\n", qr/encryption must be one of/ ], + [ "listen_address = 192.0.2.1\nimage_roots = $root\nuftp = uftp\n", qr/uftp must be an absolute path/ ], + [ "listen_address = 192.0.2.1\nimage_roots = relative\n", qr/must be absolute paths/ ], + [ "listen_address = 192.0.2.1\nimage_roots = $tmp/none\n", qr/is not a directory/ ], + [ "listen_address = 192.0.2.1\nimage_roots = $root\nallowed_networks = 192.0.2.0/33\n", qr/address\/prefix/ ], + [ "listen_address = 192.0.2.1\nimage_roots = $root\nrate = 5\n", qr/unknown key 'rate'/ ], + [ "listen_address = 192.0.2.1\nlisten_address = 192.0.2.2\nimage_roots = $root\n", qr/duplicate key/ ], + [ "listen_address 192.0.2.1\n", qr/expected key = value/ ], +) { + my (undef, $refusal) = config($case->[0]); + like($refusal // '', $case->[1], "configuration is refused: $case->[1]"); +} + +is(xCAT::UFTP::parse_fingerprint("/etc/xcat/uftp/server.key: ECDSA, curve prime256v1, fingerprint: " + . "31:94:09:9D:B1:24:3F:32:C0:B9:1A:BB:AC:F4:74:2B:80:F9:77:B2\n"), + '31:94:09:9D:B1:24:3F:32:C0:B9:1A:BB:AC:F4:74:2B:80:F9:77:B2', 'uftp_keymgt output yields the fingerprint'); +is(xCAT::UFTP::parse_fingerprint("31:94:09\n"), undef, 'a short fingerprint is refused'); + +($config) = config("listen_address = 192.0.2.1\nimage_roots = $root\nrate_kbps = 900000\n"); +is_deeply([ xCAT::UFTP::uftp_command($config, 'rootimg.cpio.gz', '/run/x/job.hosts') ], [ + '/usr/bin/uftp', '-I', '192.0.2.1', '-U', '0xC0000201', '-p', 1044, '-M', '230.4.4.1', + '-P', '230.5.5.x', '-t', 1, '-R', 900000, '-b', 1300, '-N', 98, '-Y', 'aes256-gcm', + '-h', 'sha256', '-k', '/etc/xcat/uftp/server.key', '-H', '@/run/x/job.hosts', '-x', 2, '-l', + '-D', 'rootimg.cpio.gz', '/dev/fd/3' ], + 'the sender argument vector holds only server-owned values and the inherited image descriptor'); +($config) = config("listen_address = 192.0.2.1\nimage_roots = $root\nblock_size = 8000\n"); +my @command = xCAT::UFTP::uftp_command($config, 'rootimg.cpio.gz', '/run/x/job.hosts'); +my ($block_size) = map { $command[ $_ + 1 ] } grep { $command[$_] eq '-b' } 0 .. $#command - 1; +is($block_size, 8000, 'the configured block size reaches the sender'); + +done_testing();