#!/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@@

'

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) || { echo "ERROR: dataset \`${_pds}' does not exist" >&2; return 1; }
    case "${_pmp}" in
        none)
            echo "ERROR: dataset \`${_pds}' has no mountpoint" >&2
            return 1
            ;;
        legacy)
            echo "ERROR: dataset \`${_pds}' has a \`${_mp}' mountpoint" >&2
            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="$(echo -n ${_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=""
    _veds="$(_get_zfs_dataset_for_varempty "${_mp}")"
    if [ $? -eq 0 ]; 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
    operations="$@"

    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}" ${operations}
        else
            freebsd-update -b "${directory}" ${operations}
        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
