view sbin/fjail @ 723:a97ec3f07bdb

farray.sh: REFACTOR: More flexible metadata retrieval. Using an array or alist variable name or token value (with prefix) is now supported in every function. This is possible because the value prefixes contain questin marks (?) which are not allowed in shell variable names. This again is a major precondition for recursive data structures (arrays/alists in arrays/alists).
author Franz Glasner <fzglas.hg@dom66.de>
date Sat, 05 Oct 2024 21:55:55 +0200
parents 70b16773945f
children e2f262ec2bf4
line wrap: on
line source

#!/bin/sh
# -*- indent-tabs-mode: nil; -*-
#:
#: A very minimal BSD Jail management tool.
#:
#: :Author:    Franz Glasner
#: :Copyright: (c) 2019-2024 Franz Glasner.
#:             All rights reserved.
#: :License:   BSD 3-Clause "New" or "Revised" License.
#:             See LICENSE for details.
#:             If you cannot find LICENSE see
#:             <https://opensource.org/licenses/BSD-3-Clause>
#: :ID:        @(#)@@SIMPLEVERSIONTAG@@
#:

# Module-level setting
# shellcheck disable=SC2129

: # separator for shellcheck: no module-level directives below


set -eu

# shellcheck disable=SC2034    # VERSION appears unused
VERSION='@@VERSION@@'

USAGE='
USAGE: fjail [ OPTIONS ] COMMAND [ COMMAND OPTIONS ] [ ARG ... ]

OPTIONS:

  -V    Print the program name and version number to stdout and exit

  -h    Print this help message to stdout and exit

COMMANDS:

  datasets [OPTIONS] PARENT CHILD

    Create ZFS datasets to be used within a jail

    PARENT must exist already and CHILD must not exist.

    -A        Set "canmount=noauto" for datasets
    -o        Do not create var/empty as read-only dataset but with normal settings
    -s        Also create a dataset for freebsd-update data files
    -t        Create a more tiny set of datasets
    -T        Create only an extra tiny set of datasets
    -u        Do not automatically mount newly created datasets

  mount

    See sibling tool `fzfs'"'"'

  umount

    See sibling tool `fzfs'"'"'

  privs MOUNTPOINT

    Adjust some Unix privileges to mounted jail datasets

  populate MOUNTPOINT BASETXZ

    Populate the jail directory in MOUNTPOINT with the base system in BASETXZ

  configure [OPTIONS] MOUNTPOINT

    Configure some basic parts of the system at MOUNTPOINT:
    disable root password, syslog and other basic configuration settings

    Also handle thin jails by checking whether "etc" is a symlink to
    "skeleton/etc".

    -d        Temporarily mount a devfs filesystem to MOUNTPOINT/dev

   hostid

     Print proposals for a hostuuid and hostid

  copy [OPTIONS] SOURCE-DATASET DEST-DATASET

    Copy a tree of ZFS datasets with "zfs send -R" and "zfs receive".
    Note that the destination dataset must not exist already.

    -r        Copy the datasets with the -Lec options (aka "raw")
    -u        Do not automatically mount received datasets

  freebsd-update [OPTIONS] DIRECTORY OPERATIONS...

    -c CURRENTLY-RUNNING   Assume the systen given in CURRENTLY-RUNNING is
                           installed/running at given DIRECTORY

ENVIRONMENT:

  All environment variables that affect "zfs" are effective also.

DESCRIPTION:

  All commands with the exception of "populate" require ZFS as
  filesystem.
'


_p_datadir='@@DATADIR@@'
[ "${_p_datadir#@@DATADIR}" = '@@' ] && _p_datadir="$(dirname "$0")"/../share/local-bsdtools
. "${_p_datadir}/common.subr"


# Reset to standard umask
umask 0022


#:
#: Check whether a FreeBSD version at a given location matches the userland
#: version of the host where the current process run.
#:
#: Args:
#:   $1: the location where to check for
#:   $2: an optional reference FreeBSD version to compare to (default is the
#:       version of the host)
#:
#: Returns:
#:   0: if the userland versions match, 1 otherwise
#:
#: Exit:
#:   1: on fatal errors (e.g. /bin/freebsd-version not found or errors)
#:
_has_same_userland_version() {
    local directory ref_version

    local _directory_version

    directory="$1"
    ref_version="${2:-}"

    if [ -z "${ref_version}" ]; then
        ref_version=$(/bin/freebsd-version -u) || exit 1
    fi
    _directory_version=$(chroot -- "${directory}" /bin/freebsd-version -u) || exit 1
    if [ "${ref_version%%-*}" = "${_directory_version%%-*}" ]; then
        return 0
    fi
    return 1
}


#
# "datasets" -- create the ZFS dataset tree
#
# command_datasets [ -u ] parent-dataset child-dataset
#
#    -u  do not automatically mount newly created datasets
#
command_datasets() {
    # parent ZFS dataset -- child ZFS dataset name
    local _pds _cds
    # and its mount point
    local _pmp _get
    # full name of the dataset
    local _ds
    # dynamic ZFS options  -- create cache for freebsd-update  -- use a more tiny layout
    local _zfsopts _fbsdupdate _tiny _zfsnoauto _varempty_ro

    _zfsopts=""
    _fbsdupdate=""
    _tiny="no"
    _zfsnoauto=""
    _varempty_ro="-o readonly=on"
    while getopts "oustAT" _opt ; do
        case ${_opt} in
            A)
                #
                # set canmount=noauto where otherwise canmount=on would have been set
                # or inherited
                #
                _zfsnoauto="-o canmount=noauto"
                ;;
            o)
                # Clear out the default setting of creating var/empty as read-only dataset
                _varempty_ro=""
                ;;
            t)
                # use a more tiny layout
                _tiny="yes"
                ;;
            T)  # extra tiny layout
                _tiny="extra"
                ;;
            u)
                # do not mount newly created datasets
                _zfsopts="${_zfsopts} -u"
                ;;
            s)
                # create also a dataset for freebsd-update data
                _fbsdupdate="yes"
                ;;
            \?|:)
                return 2;
                ;;
        esac
    done
    shift $((OPTIND-1))
    OPTIND=1

    _pds="$1"
    if [ -z "${_pds}" ]; then
        echo "ERROR: no parent dataset given" >&2
        return 2
    fi
    _pmp="$(zfs list -H -o mountpoint -t filesystem "${_pds}" 2>/dev/null)" || { err "dataset \`${_pds}' does not exist"; return 1; }
    case "${_pmp}" in
        none)
            err "dataset \`${_pds}' has no mountpoint"
            return 1
            ;;
        legacy)
            err "dataset \`${_pds}' has a \`${_pmp}' mountpoint"
            return 1
            ;;
        *)
            # VOID
            ;;
    esac
    _cds="$2"
    if [ -z "${_cds}" ]; then
        echo "ERROR: no child dataset given" >&2
        return 2
    fi
    _ds="${_pds}/${_cds}"
    echo "Resulting new root dataset is \`${_ds}' at mountpoint \`${_pmp}/${_cds}'"
    if zfs list -H -o mountpoint -t filesystem "${_ds}" >/dev/null 2>/dev/null; then
        echo "ERROR: dataset \`${_ds}' does already exist" >&2
        return 1
    fi

    #
    # NOTE: For BEs these directory will be *excluded* from the BE
    #
    #   /tmp
    #   /usr/home
    #   /usr/ports
    #   /usr/src
    #   /var/audit
    #   /var/crash
    #   /var/log
    #   /var/mail
    #   /var/tmp
    #
    zfs create ${_zfsopts} ${_zfsnoauto} -o atime=off                                                        "${_ds}"
    zfs create ${_zfsopts} ${_zfsnoauto} -o sync=disabled -o setuid=off                                      "${_ds}/tmp"
    if [ "${_tiny}" != "extra" ]; then
        if [ "${_tiny}" = "yes" ]; then
            zfs create ${_zfsopts} -o canmount=off                                                           "${_ds}/usr"
        else
            zfs create ${_zfsopts} ${_zfsnoauto}                                                             "${_ds}/usr"
        fi
        zfs create ${_zfsopts} ${_zfsnoauto} -o setuid=off                                                   "${_ds}/usr/home"
        zfs create ${_zfsopts} ${_zfsnoauto}                                                                 "${_ds}/usr/local"
    fi
    if [ \( "${_tiny}" = "yes" \) -o \( "${_tiny}" = "extra" \) ]; then
        zfs create ${_zfsopts} -o canmount=off                                                               "${_ds}/var"
    else
        zfs create ${_zfsopts} ${_zfsnoauto}                                                                 "${_ds}/var"
    fi
    if [ "${_tiny}" != "extra" ]; then
        zfs create ${_zfsopts} ${_zfsnoauto} -o exec=off -o setuid=off                                                      "${_ds}/var/audit"
        zfs create ${_zfsopts} ${_zfsnoauto} -o exec=off -o setuid=off                                                      "${_ds}/var/cache"
        zfs create ${_zfsopts} ${_zfsnoauto} -o exec=off -o setuid=off -o primarycache=metadata -o compression=off          "${_ds}/var/cache/pkg"
        zfs create ${_zfsopts} ${_zfsnoauto} -o exec=off -o setuid=off -o compression=off                                   "${_ds}/var/crash"
    fi
    if [ "$_fbsdupdate" = "yes" ]; then
        if [ \( "${_tiny}" = "yes" \) -o \( "${_tiny}" = "extra" \) ]; then
            zfs create ${_zfsopts} -o canmount=off -o exec=off -o setuid=off                                 "${_ds}/var/db"
        else
            zfs create ${_zfsopts} ${_zfsnoauto} -o exec=off -o setuid=off                                   "${_ds}/var/db"
        fi
        zfs create ${_zfsopts} ${_zfsnoauto} -o exec=off -o setuid=off -o primarycache=metadata -o compression=off          "${_ds}/var/db/freebsd-update"
    fi
    zfs create ${_zfsopts} ${_zfsnoauto} ${_varempty_ro} -o exec=off -o setuid=off                                          "${_ds}/var/empty"
    zfs create ${_zfsopts} ${_zfsnoauto} -o exec=off -o setuid=off -o primarycache=metadata                                 "${_ds}/var/log"
    zfs create ${_zfsopts} ${_zfsnoauto} -o exec=off -o setuid=off -o atime=on                                              "${_ds}/var/mail"
    zfs create ${_zfsopts} ${_zfsnoauto} -o sync=disabled -o exec=off -o setuid=off -o compression=off -o primarycache=all  "${_ds}/var/run"
    zfs create ${_zfsopts} ${_zfsnoauto} -o sync=disabled -o setuid=off                                                     "${_ds}/var/tmp"
}


#
# "populate" -- populate the datasets with content from a FreeBSD base.txz
#
# command_populate mountpoint basetxz
#
command_populate() {
    # MOUNTPOINT -- base.txz
    local _mp _basetxz

    _mp="$1"
    _basetxz="$2"

    if [ -z "${_mp}" ]; then
        echo "ERROR: no mountpoint given" >&2
        return 2
    fi
    if [ -z "${_basetxz}" ]; then
        echo "ERROR: no base.txz given" >&2
        return 2
    fi
    if [ ! -d "${_mp}" ]; then
        echo "ERROR: mountpoint \`${_mp}' does not exist" >&2
        return 1
    fi
    if [ ! -r "${_basetxz}" ]; then
        echo "ERROR: file \`${_basetxz}' is not readable" >&2
        return 1
    fi

    #
    # Handle /var/empty separately later: could be already there and
    # mounted read-only.
    #
    tar -C "${_mp}" --exclude=./var/empty -xJp -f "${_basetxz}" || { echo "ERROR: tar encountered errors" >&2; return 1; }
    if [ -d "${_mp}/var/empty" ]; then
        #
        # If /var/empty exists already try to extract with changing the
        # flags (e.g. `schg'). But be ignore errors here.
        #
        tar -C "${_mp}" -xJp -f "${_basetxz}" ./var/empty || { echo "tar warnings for handling ./var/empty ignored because ./var/empty exists already" >&2; }
    else
        # Just extract /var/empty normally
        tar -C "${_mp}" -xJp -f "${_basetxz}" ./var/empty || { echo "ERROR: tar encountered errors" >&2; return 1; }
    fi

    find "${_mp}/boot" -type f -delete
}


#
# "hostid" -- print a proposal for hostid/hostuuid settings in a jail
#
# command_hostid
#
command_hostid() {
    #
    # hostid and hostuuid should be set (at least for consistency ressons)
    # in vnet jails (see /etc/rc.d/hostid and /etc/rc.d/hostid_save).
    # They can be set in the jail.conf.
    # Print one here that can be pasted into the jail.conf if needed.
    #
    # hostid and hostuuid for non-vnet jails are inherited from the parent/host.
    #
    # See also /etc/rc.d/hostid and /etc/rc.d/hostid_save.
    #
    local _new_hostuuid _new_hostid
    _new_hostuuid="$(uuidgen)"
    _new_hostid="$(printf "%s" "${_new_hostuuid}" | /sbin/md5)"
    _new_hostid="0x${_new_hostid%%????????????????????????}"

    echo "Proposed hostuuid/hostid:"
    echo "  host.hostuuid = \"${_new_hostuuid}\";"
    echo "  host.hostid = $((_new_hostid));"
    #echo "  host.hostid = ${_new_hostid};"
}


#
# "configure" -- configure the mountpoint
#
# command_configure mountpoint
#
command_configure() {
    # mountpoint
    local _mp
    local _opt_devfs

    local _pcl _umount_devfs

    _umount_devfs=""

    _opt_devfs=""
    while getopts "d" _opt ; do
        case ${_opt} in
            d)
                _opt_devfs="yes"
                ;;
            \?)
                return 2;
                ;;
            *)
                echo "ERROR: option handling failed" 1>&2
                return 2
                ;;
        esac
    done
    shift $((OPTIND-1))
    OPTIND=1

    _mp="$1"

    if [ -z "${_mp}" ]; then
        echo "ERROR: no mountpoint given" >&2
        return 2
    fi
    if [ ! -d "${_mp}" ]; then
        echo "ERROR: mountpoint \`${_mp}' does not exist" >&2
        return 1
    fi

    if [ -c "${_mp}/dev/null" ]; then
        if [ "${_opt_devfs}" = "yes" ]; then
            echo "WARNING: devfs is already mounted - mounting skipped"
        fi
    else
        if [ "${_opt_devfs}" = "yes" ]; then
            echo "Mounting devfs"
            /sbin/mount -t devfs devfs "${_mp}/dev"
            _umount_devfs="yes"
        else
            echo "ERROR: a working devfs is needed at \`{_mp}/dev' (use \`-d')" >&2
            return 1
        fi
    fi

    # Deactive the by default empty root password
    pw -R "${_mp}" usermod -w no -n root

    if [ -f "${_mp}/etc/defaults/rc.conf" ]; then

        sysrc -R "${_mp}" sendmail_enable=NONE
        sysrc -R "${_mp}" clear_tmp_enable=YES
        sysrc -R "${_mp}" clear_tmp_X=NO
        sysrc -R "${_mp}" syslogd_flags=-ss
        sysrc -R "${_mp}" bsdstats_enable=NO       # no automatic BSD stats when booting (for periodic see below)
    else
        echo "WARNING: No \"${_mp}/etc/defaults/rc.conf\": not configuring \"rc.conf\""
    fi

    if [ -f "${_mp}/usr/share/zoneinfo/Europe/Berlin" ]; then
        # Timezone to CET
        if [ ! -f "${_mp}/etc/localtime" ]; then
            echo "Setting timezone to Europe/Berlin"
            # Handle thin jails automatically (but check expectations very strictly)
            if [ \( -L "${_mp}/etc" \) -a \( "$(readlink "${_mp}/etc")" = "skeleton/etc" \) ]; then
                ln -s ../../usr/share/zoneinfo/Europe/Berlin "${_mp}/etc/localtime"
            else
                ln -s ../usr/share/zoneinfo/Europe/Berlin "${_mp}/etc/localtime"
            fi
            echo "Europe/Berlin" > "${_mp}/var/db/zoneinfo"
        else
            echo "WARNING: \"${_mp}/etc/localtime\" exists already -- not changed"
        fi
    else
        echo "WARNING: No timezone data file found at \"${_mp}/usr/share/zoneinfo/Europe/Berlin\": skipping timezone setup"
    fi

    # resolv.conf
    if [ ! -f "${_mp}/etc/resolv.conf" ]; then
        echo "Copying the host's resolv.conf into the jail"
        cp -p /etc/resolv.conf "${_mp}/etc/resolv.conf"
    else
        echo "WARNING: \"${_mp}/etc/resolv.conf\" exists already -- not changed"
    fi

    # Call newaliases within the jail
    echo "Calling \"newaliases\""
    chroot -- "${_mp}" /usr/bin/newaliases

    _pcl="${_mp}/etc/periodic.conf.local"
    if [ ! -f "${_pcl}" ]; then
        echo "Adjusting periodic.conf.local"
        echo "Periodic script log into files ..."
        echo "daily_output=\"/var/log/daily.log\"" > "${_pcl}"
        echo "weekly_output=\"/var/log/weekly.log\"" >> "${_pcl}"
        echo "monthly_output=\"/var/log/monthly.log\"" >> "${_pcl}"
        echo "daily_status_security_output=\"/var/log/security\"" >> "${_pcl}"
        echo "weekly_status_security_output=\"/var/log/security\"" >> "${_pcl}"
        echo "monthly_status_security_output=\"/var/log/security\"" >> "${_pcl}"

        echo "security_status_chkmounts_enable=\"NO\"" >> "${_pcl}"

        echo "Disable some scripts that are enabled by default ..."
        echo "daily_ntpd_leapfile_enable=\"NO\"" >> "${_pcl}"
        echo "daily_status_zfs_zpool_list_enable=\"NO\"" >> "${_pcl}"
        echo "daily_status_disks_enable=\"NO\"" >> "${_pcl}"
        echo "daily_status_uptime_enable=\"NO\"" >> "${_pcl}"

        #
        # bsdstats
        #
        echo "" >> "${_pcl}"
        echo "#" >> "${_pcl}"
        echo "# bsdstats" >> "${_pcl}"
        echo "#" >> "${_pcl}"
        # Disabled by default but make it more explicit
        echo "monthly_statistics_enable=\"NO\"" >> "${_pcl}"
        # If enabled: because we are in a jail there are no devices
        echo "monthly_statistics_report_devices=\"NO\"" >> "${_pcl}"
        # If enabled: report ports
        echo "monthly_statistics_report_ports=\"YES\"" >> "${_pcl}"

        echo "Creating system logfiles that are marked for automatic creation ..."
        chroot -- "${_mp}" /usr/sbin/newsyslog -CN

    else
        echo "WARNING: \"${_pcl}\" exists already -- not changed"
    fi

    command_hostid

    if [ "${_umount_devfs}" = "yes" ]; then
        echo "Unmounting devfs"
        /sbin/umount "${_mp}/dev"
    fi
}


#
# "copy" -- ZFS copy of datasets
#
# command_copy source-dataset destination-dataset
#
command_copy() {
    # source dataset -- destination dataset
    local _source _dest
    # dynamic ZFS options -- ZFS copy options
    local _zfsopts _zfscopyopts

    _zfsopts=""
    _zfscopyopts=""
    while getopts "ru" _opt ; do
        case ${_opt} in
            r)
                # Use raw datasets
                _zfscopyopts="-Lec"
                ;;
            u)
                # do not mount newly created datasets
                _zfsopts="${_zfsopts} -u"
                ;;
            \?|:)
                return 2;
                ;;
        esac
    done
    shift $((OPTIND-1))
    OPTIND=1

    _source="$1"
    if [ -z "${_source}" ]; then
        echo "ERROR: no source dataset given" >&2
        return 2
    fi
    _dest="$2"
    if [ -z "${_dest}" ]; then
        echo "ERROR: no source dataset given" >&2
        return 2
    fi
    zfs send -R ${_zfscopyopts} -n -v "${_source}" || { echo "ERROR: ZFS operation failed in no-op mode" >&2; return 1; }
    zfs send -R ${_zfscopyopts} "${_source}" | zfs receive ${_zfsopts} "${_dest}"  || { echo "ERROR: ZFS operation failed" >&2; return 1; }
}


#
# "privs" -- adjust privileges
#
# To be used when all ZFS datasets are mounted.
#
command_privs() {
    # mountpoint
    local _mp _d _veds _get _vestatus

    _mp="$1"
    if [ -z "${_mp}" ]; then
        echo "ERROR: no mountpoint given" >&2
        return 2
    fi
    if [ ! -d "${_mp}" ]; then
        echo "ERROR: directory \`${_mp}' does not exist" >&2
        return 1
    fi
    for _d in tmp var/tmp ; do
       chmod 01777 "${_mp}/${_d}"
    done
    chown root:mail "${_mp}/var/mail"
    chmod 0775 "${_mp}/var/mail"

    #
    # Handle <mountpoint>/var/empty specially:
    # make it writeable temporarily if it is mounted read-only:
    #
    _vestatus=""
    if _veds="$(_get_zfs_dataset_for_varempty "${_mp}")" ; then
        _vestatus=$(zfs list -H -o readonly -t filesystem ${_veds} 2>/dev/null) || { echo "ERROR: cannot determine readonly status of ${_mp}/var/empty" >&2; return 1; }
        if [ "${_vestatus}" = "on" ]; then
            zfs set readonly=off ${_veds} 1> /dev/null || { echo "ERROR: cannot reset readonly-status of ${_mp}/var/empty" >&2; return 1; }
        fi
    fi
    # Set the access rights and the file flags as given in mtree
    chmod 0555 "${_mp}/var/empty" || { echo "WARNING: Cannot chmod on var/empty" >&2; }
    chflags schg "${_mp}/var/empty" || { echo "WARNING: Cannot chflags on var/empty" >&2; }
    # Reset the read-only status of the mountpoint as it was before
    if [ "${_vestatus}" = "on" ]; then
        zfs set readonly=on ${_veds} 1> /dev/null || { echo "ERROR: cannot reactivate readonly-status of ${_mp}/var/empty" >&2; return 1; }
    fi
}


#:
#: Implement the "freebsd-update" command
#:
command_freebsd_update() {
    local directory   # + operations ...

    local opt_currently_running

    opt_currently_running=""
    while getopts "c:" _opt ; do
        case ${_opt} in
            c)
                opt_currently_running="$OPTARG"
                ;;
            \?|:)
                return 2;
                ;;
        esac
    done
    shift $((OPTIND-1))
    OPTIND=1

    directory="${1-}"

    [ -z "${directory}" ] && { echo "ERROR: no directory given" 1>&2; return 2; }
    [ -d "${directory}" ] || { echo "ERROR: directory \`${directory}' does not exist" 1>&2; return 1; }

    shift

    if _has_same_userland_version "${directory}" "${opt_currently_running}" ; then
        if [ -n "${opt_currently_running}" ]; then
            freebsd-update -b "${directory}" --currently-running "${opt_currently_running}" "$@"
        else
            freebsd-update -b "${directory}" "$@"
        fi
    else
        echo "ERROR: Userland version mismatch" 1>&2
        return 1
    fi
}


#
# Global option handling
#
while getopts "Vh" _opt ; do
    case ${_opt} in
        V)
            printf 'fjail %s\n' '@@SIMPLEVERSIONSTR@@'
            exit 0
            ;;
        h)
            echo "${USAGE}"
            exit 0
            ;;
        \?)
            exit 2;
            ;;
        *)
            echo "ERROR: option handling failed" >&2
            exit 2
            ;;
    esac
done

#
# Reset the Shell's option handling system to prepare for handling
# command-local options.
#
shift $((OPTIND-1))
OPTIND=1

test $# -gt 0 || { echo "ERROR: no command given" >&2; exit 2; }

command="$1"
shift

case "${command}" in
    datasets)
        command_datasets "$@"
        ;;
    mount)
        exec "$(dirname $0)/fzfs" mount "$@"
        ;;
    umount|unmount)
        exec "$(dirname $0)/fzfs" umount "$@"
        ;;
    privs)
        command_privs "$@"
        ;;
    populate)
        command_populate "$@"
        ;;
    configure)
        command_configure "$@"
        ;;
    hostid)
        command_hostid "$@"
        ;;
    copy)
        command_copy "$@"
        ;;
    freebsd-update)
        command_freebsd_update "$@"
        ;;
    *)
        echo "ERROR: unknown command \`${command}'" >&2
        exit 2
        ;;
esac